Saltar a contenido

Crear un importador

Un importador es una clase que extiende PiecesPHP\Core\DataTransfer\Import\ImportDefinition. El motor lee el archivo, reconoce las cabeceras, valida cada celda y cada fila, y solo si todas son válidas llama a tu persist(). Tú declaras las columnas, las reglas y cómo se guarda.

El código de esta página es el de ExampleUserNamesImportDefinition (src/app/classes/DataImportExportUtility/Examples/), que actualiza el nombre y los apellidos de usuarios existentes. Está probado por la suite data-transfer-examples.

1. Lo mínimo

namespace MiModulo\DataTransfer;

use PiecesPHP\Core\DataTransfer\Import\ImportDefinition;

class ProductsImportDefinition extends ImportDefinition
{
    public function key(): string { return 'products'; }          // kebab-case: forma parte del nombre de sus rutas
    public function title(): string { return __('products', 'Productos'); }
    public function allowedUserTypes(): array { return [UsersModel::TYPE_USER_ROOT]; }
    public function columns(): array { /* … paso 2 … */ }
    public function persist(array $rows): ?ImportArtifacts { /* … paso 4 … */ }
}

2. Las columnas

Cada columna es un Column($clave, $etiqueta, $obligatoria, $alias, $validador), más su ayuda y un ejemplo:

    /**
     * @return Column[]
     */
    public function columns(): array
    {
        return [
            //La cabecera del archivo se reconoce por la key, la etiqueta o un alias, sin distinguir mayúsculas.
            (new Column('username', __(self::LANG_GROUP, 'Usuario'), true, ['usuario'], function (?string $value) {
                //El validador recibe el valor ya recortado y no vacío; devuelve el mensaje o null.
                return self::userIDByUsername((string) $value) !== null ? null : sprintf(__(self::LANG_GROUP, 'Usuario: «%s» no existe.'), $value);
            }))
                ->help(__(self::LANG_GROUP, 'El usuario que se actualiza; tiene que existir.'))
                ->example('ana.perez'),
            (new Column('firstname', __(self::LANG_GROUP, 'Primer nombre'), true))
                ->example('Ana'),
            (new Column('secondname', __(self::LANG_GROUP, 'Segundo nombre')))
                ->help(__(self::LANG_GROUP, 'Vacío lo deja vacío.'))
                ->example('María'),
            (new Column('first_lastname', __(self::LANG_GROUP, 'Primer apellido'), true))
                ->example('Pérez'),
            (new Column('second_lastname', __(self::LANG_GROUP, 'Segundo apellido')))
                ->help(__(self::LANG_GROUP, 'Vacío lo deja vacío.'))
                ->example('Gómez'),
        ];
    }
  • Cabeceras: el archivo puede usar la clave, la etiqueta o un alias, sin distinguir mayúsculas. Una columna del archivo que no declaras se ignora.
  • Obligatoria: si falta la columna entera, error de cabecera; si falta el valor en una fila, error de esa fila.
  • Celdas: llegan recortadas; una vacía es «ausente» (null). Las fechas de XLSX llegan como 2026-09-17 (con hora si la tienen) y los números en crudo (1234.5), sea cual sea la configuración regional del libro.
  • Validador: recibe el valor (ya recortado y no vacío) y la fila, y devuelve el mensaje de error o null. El mensaje sale al usuario: escríbelo con __() y con el nombre de la columna delante.
  • help() y example(): salen en la tabla del formulario y como comentario del encabezado en la plantilla XLSX.

3. Reglas entre filas: validateAll()

Lo que no se ve mirando una fila sola, como un valor repetido dentro del archivo. Devuelve posición de la fila => [errores]:

    /**
     * Lo que solo se ve mirando todas las filas: el mismo usuario dos veces en el archivo.
     *
     * @param ParsedRow[] $rows
     * @return array<int,string[]> posición de la fila => errores
     */
    public function validateAll(array $rows): array
    {
        $errors = [];
        $seen = [];
        foreach ($rows as $row) {
            $username = mb_strtolower((string) $row->get('username'));
            if ($username === '') {
                continue;
            }
            if (isset($seen[$username])) {
                $errors[$row->position()][] = sprintf(__(self::LANG_GROUP, 'Usuario: «%s» se repite en la fila %d.'), $row->get('username'), $seen[$username]);
            } else {
                $seen[$username] = $row->position();
            }
        }
        return $errors;
    }

4. Guardar: persist()

Se llama solo si todas las filas son válidas, y nunca en un simulacro. Tiene que ser todo o nada: abre una transacción propia con la conexión compartida y, si algo falla, deshaz y lanza ImportPersistException con un mensaje para el usuario, sin datos internos:

    /**
     * Solo se llama si TODAS las filas son válidas, y nunca en un simulacro.
     *
     * Todo o nada: una transacción propia con la conexión compartida. Si una fila falla, no queda ninguna cambiada.
     *
     * @param ParsedRow[] $rows
     * @return ImportArtifacts|null
     * @throws ImportPersistException con un mensaje para quien importa
     */
    public function persist(array $rows): ?ImportArtifacts
    {
        $pdo = UsersModel::model()::getDb(Config::app_db('default')['db']);
        if ($pdo === null) {
            throw new ImportPersistException(__(self::LANG_GROUP, 'No se pudo guardar la importación; no se cambió ningún usuario.'));
        }
        try {
            $pdo->beginTransaction();
            foreach ($rows as $row) {
                $userID = self::userIDByUsername((string) $row->get('username'));
                if ($userID === null) {
                    throw new \RuntimeException('El usuario de la fila ' . $row->position() . ' ya no existe.');
                }
                //Solo estas columnas y por id: update() pasa los valores por marcadores, nunca en el texto del SQL.
                $model = UsersModel::model();
                $model->resetAll();
                $model->update([
                    'firstname' => (string) $row->get('firstname'),
                    'secondname' => (string) $row->get('secondname'),
                    'firstLastname' => (string) $row->get('first_lastname'),
                    'secondLastname' => (string) $row->get('second_lastname'),
                ])->where(new WhereSegment([WhereItem::isEqual('id', $userID)]))->execute();
            }
            $pdo->commit();
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }
            //El detalle va al registro de errores; quien importa recibe un mensaje sin datos internos.
            log_exception($e);
            throw new ImportPersistException(__(self::LANG_GROUP, 'No se pudo guardar la importación; no se cambió ningún usuario.'));
        }
        //Este importador no genera entregables (como las credenciales de UsersImportDefinition).
        return null;
    }
  • Actualiza solo las columnas que toca y por marcador (update() con WhereSegment), nunca concatenando valores en el SQL.
  • Trampa conocida: EntityMapper::save() no deja el id nuevo en el objeto. Si tu persist() crea registros y necesita su id, léelo con getInsertIDOnSave() o getLastInsertID() (así lo hace UsersModel).
  • Entregables de un solo uso: si tu importación genera algo que solo se entrega una vez (como las contraseñas del importador de usuarios), devuélvelo en un ImportArtifacts y declara mayProduceArtifacts(). El panel lo descarga en el momento, la terminal lo escribe en credentials-out, y nunca se guarda en disco, base, sesión ni log.

5. Registrar

En el routes() de tu módulo:

DataImportExportUtilityRoutes::importer($group, ExampleUserNamesImportDefinition::class);
DataImportExportUtilityRoutes::exporter($group, ExampleUsersReportExportDefinition::class);

Crea tres rutas con nombre propio, y el nombre es el permiso:

Ruta Qué hace
data-transfer-import-<key> El formulario
data-transfer-import-<key>-action Recibe el archivo (y el simulacro)
data-transfer-import-<key>-template La plantilla, ?format=xlsx (por defecto) o csv

Con el módulo apagado (DATA_IMPORT_EXPORT_MODULE en false), registrar no hace nada ni da error.

6. Opciones

Método Por defecto Para qué
acceptedExtensions() ['xlsx'] Añade 'csv' si tu importador lo admite (el de usuarios lo hace)
maxSizeMB() 5 Tamaño máximo del archivo
maxRows() 5.000 Filas máximas
mayProduceArtifacts() false true si puede generar entregables; la terminal exige entonces credentials-out
interfaceLevel() INTERFACE_AUTO Ver abajo
description() '' La frase que sale en la portada, junto al título
formPartial() / customView() null Vistas propias en los niveles 2 y 3

7. Niveles de interfaz

Nivel Qué hay
0 · INTERFACE_NONE Ninguna ruta web: solo la terminal
1 · INTERFACE_AUTO Formulario, subida y plantilla
2 · INTERFACE_EXTENDED Lo del 1, más «Solo validar» (simulacro) y el hueco de formPartial()
3 · INTERFACE_CUSTOM Tu vista (customView()) en lugar del formulario, con las mismas rutas

Las vistas propias tienen que ser un .php dentro del proyecto; otra ruta es un error de programación que salta al registrar o al pintar.

El simulacro valida todo (cabeceras, filas, validateAll()) y nunca llama a persist(): el informe dice «simulacro», y no hay entregables. En la terminal está siempre disponible con dry-run=yes.

8. Probarlo

Copia la suite UnitTest-DataTransferExamples.php (src/app/core/system-controllers/local-tests/) como punto de partida. Lo que conviene probar:

  • la plantilla y sus comentarios;
  • un simulacro con un archivo válido: cero errores y persist() sin llamar;
  • un archivo con errores: cada uno en su fila;
  • una importación real y una con un fallo a mitad que no deje nada cambiado. Esta escribe de verdad: antes, bin/cli db-backup; usa datos con un prefijo reconocible y bórralos al terminar.

La organización, en la importación de usuarios

La columna «organización» (también vale la cabecera «código de organización») se rellena con el código de la organización, del estilo ORG8545741. Lo encuentras en el listado de organizaciones del panel y en su ficha.

Reglas, por orden:

  1. si la celda trae un código válido, el usuario va a esa organización;
  2. si la celda está vacía y quien importa tiene organización, hereda la suya;
  3. si la celda está vacía y quien importa tampoco tiene organización, esa fila falla y el informe lo dice: «Organización: la fila N no trae ninguna y hay que poner su código, porque quien importa no tiene una que heredar.»;
  4. si la celda trae algo que no existe, la fila falla; no se sustituye por la de quien importa: «Organización: la fila N trae «X», que no es el código ni el id de ninguna organización.»

La celda admite también el identificador interno de la organización, para que las plantillas repartidas antes de la v8 sigan funcionando. Lo que se recomienda, y lo que escribe la exportación, es el código.

Un fallo en una fila impide la importación entera: no se guarda ninguna. Corrige el archivo y vuelve a subirlo.