Skip to main content

Importador (plugin)

Plugin de importação de dados por planilha (CSV e XLSX) e de fotos em lote, com upsert por chave, transação por linha e relatório de rejeitados. Substitui, com folga, a importação nativa de /configuracoes/internal#import, sem alterar uma linha do core.

  • Repositório: git@bitbucket.org:alesinicio/accelero_plugin_importacao.git
  • Pasta instalada: plugins/accelero_plugin_importador
  • Id do manifest: importador
  • Versão: 1.0.0 — compatível com Accelero 2.17.1
Documento interno

Traz decisões, limitações do core e armadilhas que não vão para a documentação pública.


1. Por que o plugin existe

A importação nativa (Accelero\DataImport + Accelero\CSVImporter) tem cinco limitações estruturais, todas verificadas no código:

LimitaçãoCausa no core
"Não use ponto-e-vírgula nem quebra de linha"CSVImporter::getData() faz explode("\n") + explode(';'), sem tratar aspas
Só cria, nunca atualizacada linha faz clone $referenceObj
Upload de no máximo 1 MBdata-files-maxsize="1048576" na view cfgEdit-internal
Só o perfil dev enxergacheckPermissao($usu, 'dev') + AdminMiddleware
Uma linha ruim descarta o lote inteirosó faz commit se a contagem de erros for zero

Além disso não importa foto, não importa faixa horária nem permissão de acesso, e o relatório de erros vive no cache com TTL de 2 horas.

Existe ainda uma segunda importação nativa, pouca gente sabe: em Operações em lote > Associação de permissões individuais o Accelero aceita um CSV (Documento;Data inicial;Data final;Areas). Ela só cria (reimportar duplica), grava tudo no padrão (paaDias = '1111111', feriado liberado, escort desligado) e a chave é só o documento.


2. Instalação

O plugin é entregue como pacote cifrado. Um .zip comum é recusado: a tela de plugins decifra com Encryption::decryptFile e a chave de PluginEncryption, abre o zip de dentro, extrai em plugins/, roda o post_install.php e aplica as migrations.

Plugin instalado e habilitado na tela de plugins

O botão de status liga e desliga sem desinstalar (grava em plugins/.disabled). Remover pelo X reverte as migrations: tabela, permissão e configuração saem do banco.

Para ATUALIZAR, instale por cima. Não remova antes

O add() já apaga a pasta antes de extrair (removePluginByManifestID com runRollbackMigrations: false), então adicionar o pacote novo substitui o antigo. Remover primeiro é pior: o X reverte as migrations, e com elas vão o histórico de importações e a marcação da permissão nos perfis (a permissão renasce com outro prmID).

Nunca exclua o plugin com uma importação rodando

Aconteceu, e o estrago é este: o uninstall.php chama Accelero::restartServices(), que derruba o asyncprocessor e mata a importação em curso; em seguida o rollback das migrations dropa a importador_jobs; e a migration seguinte, ao tentar um ALTER numa tabela que já não existe, estoura. Como a exceção sobe antes do removeDir, o plugin fica instalado e sem tabela, e toda tela dele passa a responder db-811-0 (que é QueryException com código 0 — a assinatura de um prepare falhando por tabela inexistente).

Recuperação, sem acesso ao servidor: instale o pacote por cima. O add() não reverte migrations, e o CREATE TABLE IF NOT EXISTS recria a tabela. Perde-se o histórico; as pessoas já importadas continuam no cadastro, porque nada disso desfaz gravação.

A partir da 1.0.0 as migrations toleram a tabela ausente (conferem no information_schema em vez de usar ALTER ... IF EXISTS), então a exclusão não trava mais no meio. Cancele a importação pela tela antes, mesmo assim.

Permissão

Depois de instalar, conceda Importação por planilha e fotos ao perfil, em Configurações > Perfis > (perfil) > Permissões. Sem ela nem o menu aparece.

As duas permissões de importação na tela de perfil

Atenção à vizinha: "Importação de dados - permissões individuais" é do core (migration 2025092211160000_add_batchAdhocImport_permission) e libera a tela de Operações em lote, não o plugin. Foi por causa dessa confusão que a permissão do plugin deixou de se chamar "Importação de dados por planilha".


3. As telas

Histórico

/importador, no menu lateral. Cada linha é uma execução, com contagens e o relatório para baixar. O estado fica em tabela própria (importador_jobs), e não em cache: o operador pode fechar o navegador e ver o resultado depois.

A coluna Situação carrega o progresso enquanto a importação anda ("Em andamento (136 de 256)") e, passados dois minutos sem avançar, quanto tempo faz que ela não anda — porque na tela uma importação lenta e uma travada têm exatamente a mesma cara. A coluna Observação diz por que uma importação falhou, ou que ela precisou ser retomada no meio do caminho.

Histórico de importações

Assistente

As colunas aceitas são geradas a partir da declaração da entidade, e não de um CSV estático que diverge do código na primeira alteração. O mesmo texto alimenta a tabela da tela, o comentário do cabeçalho no Excel e a mensagem ao clicar na célula.

Assistente de nova importação

Limite de licença, antes do envio

Quando o cadastro é limitado por licença, o número aparece na hora de escolher a entidade:

Licença com folga

E em vermelho quando não cabe mais nada, caso em que a importação não vai gravar:

Licença esgotada

Envio

A área de arrastar e soltar mostra nome e tamanho do arquivo. O envio é em blocos de 5 MB, com retomada, então não depende do post_max_size do servidor.

Arquivo escolhido

Confirmação

A importação definitiva pede o código aleatório (SNFWHelpers.confirmWithCaptcha), o mesmo mecanismo das ações sem volta. Testar sem gravar não pede: exigir o código ali ensinaria o operador a digitá-lo no automático, tirando o peso do aviso justamente na hora que importa.

Código de confirmação

Cancelar no meio

O botão aparece na listagem enquanto a importação está aguardando ou em andamento, e some depois. Quem o monta é o servidor (Importacao::listModelPostQueryFilter), numa célula html-content, porque só ele conhece o status numérico da linha: a view recebe a situação já traduzida ("Em andamento (136 de 256)") e decidir por aquele texto seria decidir por um rótulo. O clique é pego por delegação em body, então botão criado depois da tela funciona igual.

A tela não encerra a importação: ela deixa o pedido. Quem trabalha é outro processo, e encerrar aqui deixaria um bloco gravando linhas depois de a tela já ter dado a importação por cancelada. O status vai para Cancelando..., e o bloco em execução pergunta ao banco a cada 25 linhas se ainda deve continuar — uma consulta a uma coluna, barata perto de qualquer linha de importação, que grava. Medido: para em 1 segundo.

A importação que ainda não começou é encerrada na hora, sem esperar por ninguém: do contrário o operador veria "Cancelando..." por um minuto e meio num job que nunca saiu do lugar.

Cancelar não é desfazer

As linhas já importadas continuam gravadas, e a mensagem diz isso com todas as letras, porque é a primeira dúvida de quem cancela. O relatório do que foi feito até ali fica disponível para download, como numa conclusão normal.

Uma importação por vez

A segunda importação é recusada enquanto houver outra em andamento, dizendo qual está ocupando a vez: "Já existe uma importação em andamento (#12, pessoa, 136 de 256). Espere ela terminar, ou cancele por lá para começar outra." Vale para planilha e fotos, que disputam a mesma fila.

Não é proteção contra corrupção (o Accelero garante a unicidade de código e documento de qualquer jeito), e sim contra confusão e lentidão: duas importações competem pelo mesmo banco, e se as planilhas se cruzarem o operador recebe rejeições por duplicidade que não tem como explicar. Um job preso em andamento também bloqueia, de propósito — com o botão de cancelar, o operador destrava sozinho, e isso é mais previsível do que o sistema adivinhar por tempo se a importação ainda está viva.

Configuração (Configurações > Importação de dados)

Os limites operacionais não estão no código: ficam no alias importador da tabela configuracoes, para o suporte calibrar num cliente sem depender de nova versão do plugin. Valor não informado (ou zero) cai no padrão.

ChavePadrãoPara que serve
max_photo_size_kb500Acima disso o navegador reduz a foto antes de enviar
max_photo_reject_kb10240Acima disso a foto é recusada, sem tentar reduzir
max_package_size_mb1024Teto do pacote de fotos, recusado na tela antes de subir
chunk_size_mb5Tamanho do bloco no upload em partes
suggested_batch_size500Lote sugerido na tela; é conselho, não trava
photo_resize_enabledligadoDesligar faz a foto grande ser recusada em vez de reduzida
bloco_segundos20Quanto tempo cada bloco da importação trabalha antes de passar a vez

bloco_segundos é o que se mexe num ambiente que derruba processos mais cedo do que o esperado: encurtar aumenta o retrabalho de reler a planilha, mas cada execução vive menos.

Fotos em lote

Importação de fotos

.zip: o seletor de arquivos do navegador não permite escolher uma pasta (no Mac, clicar nela apenas entra), então pedir "a pasta" seria pedir o impossível. O nome do arquivo, sem extensão, tem que ser exatamente o código ou o documento — sem tolerância: 00012345 não casa com 12345. Sufixos _2 e _3 endereçam a segunda e a terceira foto.


4. Como a importação roda

O controller nunca importa nada: ele grava o registro em importador_jobs, publica no MQTT e responde na hora. Quem trabalha é o asyncprocessor — e, mais precisamente, um processo filho dele: GenericAsyncRunnerJob recebe a mensagem e chama runAsAsync(), que dispara php jobs/async_jobs/generic_async_runner.php ... &. O loop do worker volta em milissegundos.

O que esse processo filho não faz mais é rodar do começo ao fim da planilha.

Blocos de 20 segundos

Cada execução trabalha por bloco_segundos (padrão 20, ajustável na configuração do plugin, alias importador da tabela configuracoes), grava onde parou e dispara a continuação, que nasce como um processo novo. A razão é a experiência de um cliente cuja importação morria sempre no meio, em posições diferentes e sem erro no log: era o vazamento de memória do saveRecord() (ver as armadilhas), mas o remédio não podia ser só corrigir aquele vazamento. Processo longo é frágil por mais motivos do que memória: o healthcheck do container responde com kill 1, um deploy reinicia tudo, e num SaaS pode haver outra vigilância que não conhecemos. Nenhuma dessas mortes passa por um catch.

Com blocos:

  • nenhuma execução vive o bastante para incomodar uma vigilância nem para acumular memória;
  • um bloco morto custa, no pior caso, o último trecho, e não a importação inteira;
  • cada bloco é um PHP novo, que lê o plugin do disco: atualizar o plugin passa a valer para a próxima importação sem reiniciar o worker.

O estado fica em quatro colunas do job: impCheckpoint (última linha confirmada), impBloco (quantos blocos rodaram, que também vai na mensagem MQTT), impRetomadas e impLock (o trinco). E os estados possíveis, em JobStatusEnum, são sete:

ValorEstadoSignifica
0AguardandoRegistrada, nenhum bloco pegou ainda
1Em andamentoUm bloco está trabalhando (ou o último morreu e o retomador ainda não passou)
2ConcluídaChegou ao fim da planilha
3Concluída com rejeiçõesChegou ao fim, e há linhas no relatório
4FalhouErro que impediu a importação, ou teto de retomadas atingido
5Cancelando...Pedido registrado, esperando o bloco em curso notar
6CanceladaParou a pedido; o que foi gravado continua gravado

isFinished() cobre 2, 3, 4 e 6 — é o que impede o retomador de ressuscitar uma importação cancelada. isCancelable() cobre 0 e 1, e é o que decide se o botão de cancelar aparece.

O relógio do bloco só começa quando a primeira linha nova é processada. Cada bloco relê o arquivo desde o começo para pular o que já foi feito, e isso não é de graça: numa planilha de 25.000 linhas, percorrer o arquivo inteiro leva 5,1 segundos, e na carga de teste os primeiros blocos faziam 750 linhas contra 285 dos últimos. Descontar o tempo de pular do orçamento do bloco resolve o desperdício; um teto de 45 segundos para o bloco inteiro impede que a soma volte a produzir processo longo.

O que fatiar exigiu

Vale para qualquer trabalho fatiado, não só para este plugin:

CuidadoPor quê
Gravar contagens, ponto de retomada e relatório juntosGravar as contagens sem o ponto (ou o contrário) faz o trecho refeito contar duas vezes
Drenar as rejeições do resumo ao gravá-lasSenão o bloco seguinte escreve de novo o que o anterior já escreveu
Levar o número do bloco na mensagem MQTTAsyncProcessorMQTT::handle descarta mensagem repetida com o mesmo conteúdo dentro de dez segundos: a continuação de um bloco rápido seria engolida em silêncio
Um trinco no registro do jobDois processos no mesmo job duplicam linhas no relatório e desencontram as contagens
Um retomadorO bloco que morre não dispara o seguinte, e a importação ficaria "Em andamento" para sempre

O trinco é tomado por UPDATE condicional na coluna impLock, que o banco serializa, e conferido relendo o registro: "está travado?" seguido de "então travo" tem espaço no meio para o outro processo entrar. Tem prazo (90 s, contados de impUpdatedAt), para não sobreviver ao processo que o tomou.

O retomador (RetomadorDeImportacoes) roda quando a tela consulta o estado ou o histórico, e retoma do ponto gravado toda importação sem sinal de vida há mais de 90 segundos. Com teto: passadas 20 retomadas sem sair do lugar, a importação é encerrada dizendo em que linha emperrou, em vez de renascer para sempre. O gatilho é a tela porque é o gatilho mais confiável que um plugin tem sem depender do agendador do core, e casa com o momento em que a informação importa: quem está olhando quer ver a importação andar.

O fim normal de um bloco não é uma interrupção

A rede de segurança (register_shutdown_function) roda no encerramento do processo, e o encerramento é o mesmo nos dois casos: o bloco que morreu e o bloco que terminou o seu turno. Sem distinguir um do outro, a tela acusava "o bloco foi interrompido" a cada 20 segundos numa importação que estava indo bem. Por isso o bloco marca que terminou por conta própria, e a observação é limpa ao concluir — virando "Concluída após 1 retomada automática" quando houve percalço no meio.


5. O que o E2E provou

Roteiro em apoio/e2e-scripts/importador_capturas.py, pelo caminho do operador. As planilhas têm erro de propósito: relatório de importação perfeita não ensina nada.

A primeira importação, de áreas, sem nenhuma rejeição:

Importação de áreas concluída

Teste sem gravar, identificado como tal no histórico:

Modo teste no histórico

Importação definitiva da mesma planilha: 6 linhas, 4 criadas, 2 rejeitadas (linha sem chave e e-mail inválido). O nativo teria descartado as 6.

Importação com rejeições

Planilha de correção com três colunas (Código, Nome, E-mail): 2 atualizadas e 1 criada. Os campos que não estavam na planilha continuam intactos — é o upsert por chave.

Upsert pela planilha de correção

No cadastro, as pessoas importadas:

Pessoas importadas

E as áreas, com as colunas que o nativo não tem (repare o anti-dupla entrada ligado no Almoxarifado, que veio da planilha):

Áreas importadas


6. Entidades

Dez, incluindo as oito do nativo (todas com mais colunas do que ele) e duas que ele não faz.

EntidadeClasse do ORMChave de upsert
ÁreasAreaareDescricao
EmpresasEmpresaempCodigo
Categorias de pessoaPessoaCategoriapctCodigo
Faixas horáriasFaixaHorariafxhDescricao
FeriadosFeriadoferData
PessoasPessoapesCodigo ou pesDocumento
VeículosVeiculoveiPlaca
IdentificadoresCartaocarNumero
Áreas de acesso da categoriaLinkPessoaCategoriaAreapctID + areID
OperadoresUsuariousuEmail

Permissão individual ficou de fora de propósito. É recurso pouco usado: o acesso se dá por categoria, e a liberação individual é exceção, uma pessoa de cada vez, que se resolve na aba da pessoa. Uma planilha para isso carregaria a lista com uma entidade que quase ninguém abriria.

Vínculo que não existe recusa a linha inteira

Pessoa com categoria inexistente, identificador para pessoa que não existe, empresa com grupo inexistente: nada da linha é gravado. Garantido pela transação por linha, já que o afterSave de cada entidade roda dentro dela.

A alternativa (criar e avisar) foi descartada: pessoa sem categoria não acessa nada e alguém poderia entregar um cartão a ela; e gravar metade da linha é gravar algo que o operador não pediu. Recusar não custa retrabalho, porque o relatório traz a linha original inteira.


7. Limites de licença

Quatro dos dez cadastros são limitados: categorias, faixas horárias, identificadores e operadores. Os números vêm do próprio Accelero (LicenseControlledEntityInterface), e o plugin não guarda tabela de limites.

Duas coisas contraintuitivas, que quase viraram um aviso errado na tela mais usada:

  • a classe Pessoa é controlada por licença, mas pelo limite do DirectPass, verificado só quando pesDirectPassEnabled muda — coisa que o importador nunca faz. Nas instalações sem DirectPass esse limite é 0, e anunciá-lo faria a tela dizer "nenhum cadastro novo será aceito" em toda importação de pessoas;
  • o limite que pesa sobre pessoas é o de "Usuários ativos", que mora no identificador. A consulta do core soma pessoas habilitadas com pelo menos um identificador, mais os portadores que não são pessoa (veículo com identificador entra na mesma conta).

Consequências para planejar uma carga inicial:

  • importar pessoas não consome licença; dar o primeiro identificador a elas consome;
  • quem vai para leitor facial está nessa conta, porque o cadastro facial é feito em cima de um identificador (HikvisionIntegration::enrollFacialTemplate() recebe o Cartao);
  • desabilitar libera licença, porque a consulta filtra pesHabilitado = 1.

8. Empacotamento

docker exec <container_php> php \\
/var/www/accelero/plugins/accelero_plugin_importador/tools/empacotar.php

Roda dentro do container porque usa a chave e a rotina de cifra do próprio Accelero, o que evita copiar a chave para o repositório. O pacote sai em dist/, com 0,30 MB.

O DevTools tem um gerador equivalente (/devtools/plugingenerator) que serve para a maioria dos plugins. Aqui não serve: ele inclui tudo que está na pasta, e tests/ tem 16 MB de planilhas e fotos — passaria do limite de 8 MB da tela de upload e levaria dados de teste para o servidor do cliente.

Três detalhes que o formato exige:

  1. A pasta do plugin tem que ser a primeira entrada do zip. O Accelero lê statIndex(0) para saber onde instalar.
  2. O nome dessa pasta é fixo no empacotador, e não o nome do diretório do clone. Trocá-lo numa versão futura faria o Accelero instalar ao lado da anterior em vez de substituí-la.
  3. Toda migration precisa poder rodar de novo — ver a seção seguinte.

9. Armadilhas do Accelero descobertas aqui

Nenhuma está documentada em outro lugar, e cada uma custou uma rodada de depuração.

saveRecord() vaza 47 KB por gravação

A mais cara de todas, e ela não é do plugin. DefaultDBObject::saveRecord() resolve InterceptorHandler e EventHandler pelo container a cada gravação, e o container reconstrói as coleções toda vez, varrendo os diretórios de interceptors e de event listeners. O objeto gravado é coletado normalmente; essas coleções não.

Medido, 600 linhas por etapa:

EtapaMemória por linha
Ler a planilha0 KB
Ler + load()1,2 KB
Ler + load + apply1,3 KB
Com saveRecord()56 KB
Com saveRecord + vínculo de categoria90 KB

São 23 MB a cada 500 linhas: o processo estoura o memory_limit de 512 MB e o PHP o mata. Testado também com o ORM puro, fora do importador: 43 KB por gravação, mesmo com unset() e gc_collect_cycles(); as instâncias são coletadas (confirmado com WeakReference), o que fica pendurado são as coleções.

O contorno do plugin é fixarInjecoesDeGravacao(): resolver uma vez e fixar as instâncias no container, o que leva o vazamento a 0,0 KB por gravação sem mudar comportamento — são coleções de configuração, carregadas do disco, iguais em toda resolução. Vale para qualquer processo do Accelero que grave muitos registros, não só para o importador. A correção de verdade é no core.

Um fatal no processo filho não deixa rastro nenhum

O job roda em processo filho (GenericAsyncRunner::runAsAsyncexec(... > /dev/null 2>&1 &)), então a mensagem do PHP vai para o vazio: estouro de memória e max_execution_time não passam por catch, não chegam ao log do Accelero e não aparecem em lugar nenhum. O sintoma é uma importação que "morre sem erro".

Quando um job morre em silêncio, desconfie de memória antes de desconfiar do log, e rode o job na mão pelo CLI, onde os erros ficam visíveis.

Por que a importação é rápida no local e lenta em SaaS

Medido com 200 pessoas e o general_log do MariaDB ligado, comparando o plugin com o importador nativo:

PluginNativo
Tempo (ambiente local)7,06 s7,37 s
Statements executados4.036 (20 por linha)3.609 (18 por linha)
Transações abertas2001
Conexões ao banco2224

Duas leituras. A primeira: no local os dois têm o mesmo desempenho, e o volume de queries por linha é praticamente igual — o custo de gravar uma pessoa é do core, não do upsert do plugin (que só acrescenta a busca por código e por documento).

A segunda explica o SaaS, e está na linha das conexões. O commit() do core fecha a conexão:

public function commit() : void {
$instance = $this->getInstance();
$instance->commit();
$this->closeConnection(); // <--
}

O nativo faz um commit no arquivo inteiro e usa uma conexão. O plugin faz uma transação por linha — que é o que permite gravar as linhas boas e rejeitar só as ruins — e portanto reconecta ao banco a cada linha. Com o MySQL no container ao lado isso é invisível (handshake de ~1 ms, fsync em SSD local); com o banco em outro host, cada conexão custa dezenas de milissegundos e cada commit força escrita durável em storage de rede. Medido de ponta a ponta: 25.000 pessoas em 20 min no local e 1h53 no SaaS.

Ou seja, a lentidão não é o plugin ser pesado: é o preço da transação por linha onde conectar e confirmar custam caro. É o preço da funcionalidade que motivou o plugin, já que o nativo é rápido justamente por ser tudo ou nada.

Caminho de solução, quando for estudado: transação por lote com SAVEPOINT antes de cada linha e ROLLBACK TO SAVEPOINT na linha ruim. Preserva o relatório de rejeitados e derruba ~25.000 commits e reconexões para algumas centenas.

O healthcheck do container mata o worker parado, não o worker ocupado

O compose declara, para o asyncprocessor:

healthcheck:
test: php /var/www/accelero/jobs/command.php healthcheck async_processor || kill 1
interval: 60s

Healthcheck compara jobstatus.jobLastOnline com o relógio e sai com 1 se o último sinal de vida tem mais de 60 segundos — e aí o kill 1 derruba o container. O sinal de vida vem do keepalive publicado pelo queue_processor e tratado no loop do asyncprocessor.

Isso não atinge um job do importador, porque o job roda em processo filho e o loop continua livre para tratar o keepalive. Foi a primeira explicação para a importação que morria, e estava errada. Vale conhecer o mecanismo assim mesmo: se algum dia um plugin fizer trabalho longo dentro do loop, é exatamente isto que vai acontecer, e o remédio é a chave de cache healthcheck_suspend (com prazo, e gravada no cache real), que é o que ConcentradorSynchronizeDatabase usa ao exportar a base.

O Accelero instala um error handler que converte warning em exceção (config_bootstrap.php), e o arroba não impede isso. Apagar um arquivo que já não existe derruba o processo — no importador, derrubava justamente o bloco que tinha acabado de concluir a importação com sucesso. Use file_exists() antes.

Mensagem repetida no MQTT é descartada por dez segundos

AsyncProcessorMQTT::handle guarda o hash de tópico + conteúdo das últimas mensagens e ignora repetição dentro de dez segundos. Quem publica a continuação do próprio trabalho precisa variar o conteúdo (o importador manda o número do bloco), senão a mensagem some sem erro e o trabalho para no meio.

Campo calculado sem célula na view some sem erro

A Table casa dado e célula por classe CSS. Um campo que o servidor manda e a view não declara simplesmente não aparece: nada no console do navegador, nada no log do servidor, e o payload continua correto se alguém for conferir por ali.

Custou uma versão publicada pela metade: o botão de cancelar era montado no listModelPostQueryFilter e enviado em impAcoes, a view nunca ganhou o <span class="impAcoes html-content">, e o teste que existia olhava só o payload — passou. Quem descobriu foi o operador, olhando uma tela sem botão.

tests/e2e_tela.php cobre isso agora: compara os campos calculados (os que o payload tem e $databaseFields não) com as classes declaradas na view, e confere que o filtro de situação cobre todos os casos do enum de status. Vale para qualquer plugin com listagem própria.

Migrations de plugin rodam a cada instalação

PluginController::runPluginMigrations() chama Migration::runQuery() direto, sem consultar migrationslog. Duas consequências:

  • ALTER TABLE ADD COLUMN estoura na segunda instalação, e o operador vê apenas "plugin inválido", depois de os arquivos já terem sido extraídos. E o contorno óbvio, ADD COLUMN IF NOT EXISTS, é sintaxe do MariaDB: o MySQL a recusa. O que funciona nos dois é conferir a coluna no information_schema antes de criá-la;
  • INSERT IGNORE não protege nada em permissoes e permissoesbinds: as duas só têm chave primária no próprio id, sem índice único. Cada instalação inseria de novo, e depois da segunda a tela de perfis mostrava "Importação por planilha e fotos" duas vezes, sem o admin ter como saber qual ligar (ligar a errada não dá acesso nenhum).

A migration de permissões agora consulta antes de inserir e ainda limpa duplicatas deixadas por versões anteriores, transferindo para a linha mantida os perfis que apontavam para as cópias.

Transação e autocommit

beginTransaction() desliga o autocommit e nada o religa — nem rollback(), nem o fim do método. O sintoma foi cruel: a importação terminava certa, mas o status final, as contagens e o relatório ficavam pendentes numa transação que ninguém confirmava, e a tela mostrava "Em andamento" para sempre.

nullable: 0 recusa string vazia

Um registro novo montado a partir de planilha parcial chegava ao validador com os campos de liga/desliga nulos, e a rejeição saía como areADE: nullable, 0, ''. A regra virou: grava quando a coluna veio na planilha, e quando o registro é novo (aí com o padrão).

Coluna ambígua em qualquer consulta de tabela de ligação

A consulta de um link faz JOIN com a entidade ligada, e a coluna da chave existe dos dois lados: [['pctID', '=', $x]] estoura com "Column 'pctID' in where clause is ambiguous". Vale para LinkPessoaCategoria, LinkPessoaEmpresa, LinkPessoaVeiculo, LinkPessoaCategoriaArea e LinkUsuarioPerfil. Qualificar sempre com a tabela, como o core faz.

Custou caro: encontrei no vínculo de perfil do operador, corrigi só ali, e o defeito voltou numa importação real de 256 pessoas — as 256 rejeitadas pelo pctID. Corrigir um caso de uma classe de defeito não é corrigir a classe.

Ficou escondido porque as fixtures só tinham vínculo inexistente (para provar a rejeição) ou vazio: o caminho em que o vínculo é de fato criado nunca rodava. Regra que fica: para cada vínculo, testar os dois lados.

Associar perfil também exige Perfil::invalidatePermissaoCache(), senão o operador recém-importado entra com as permissões antigas.

Código de categoria é texto, não número

De 3 a 20 caracteres (validação na view pctEdit), e as instalações usam de "PADRAO" a "ACESSO 24H". O plugin completava com zeros à esquerda por eu ter suposto três dígitos; era invenção minha e foi removida.

Camada de tela

ArmadilhaConsequência
Permissão do ORM usa o nome curto da classe do modeloo prbGroup precisa casar com ele (o modelo se chama Importacao, não Job)
listAll() devolve a view; listData() devolve as linhasmesma URL, GET para a tela e POST para os dados
Tudo é POST, inclusive o menu lateralrota de tela só com GET responde 404 pelo menu, e o sintoma engana: o operador vê a tela anterior com "Erro! Tente novamente"
?d=true faz o DataFlusher responder só os dadossem isso toda rota devolve a página inteira
getParsedBody() não lê JSONcorpo JSON chega vazio e vira HTTP 400
A view é processada como HTML no servidortags de fechamento dentro de literais JS são removidas, e acentos chegam como entidades
O public/ do plugin não é servido pelo nginxo plugin serve os próprios arquivos por rota
btn-back volta para a homenum plugin, aponte o destino com btn-default-click + data-endpoint
<button data-endpoint> é tratado como ação de dadospara abrir outra tela, <a href>
Botão dentro de linha de tabela precisa ser btn-save-inlinecom btn-custom-action a resposta substitui a lista inteira
setPesFotoByID() descarta em silêncio valor com menos de 100 caracterespassar o caminho do arquivo não grava nada e não dá erro; a foto tem que ir como data URI
Row::fromValues() do OpenSpout recebe a altura no segundo parâmetropara estilo é fromValuesWithStyle()
Download precisa de resposta binária com Content-Dispositiono download do DataFlusher só funciona por AJAX do framework
Texto de dicionário passa por sprintfum % solto estoura com "2 arguments are required, 1 given"; escrever %%

10. Desenvolvimento

O ambiente local é o descrito em apoio/GUIA_E2E_SCREENSHOTS.md, no repositório do Learn. Para editar o plugin sem empacotar a cada mudança, existe um override de compose que monta o repositório dentro do container:

cd /Users/<voce>/temp/accelero-onpremises/accelero
accelero_deploy=accelero HOMEDIR=/Users/<voce>/temp/accelero-onpremises \\
docker compose --project-directory . -f modules_core.yml -f modules_listeners.yml \\
-f modules_importador_dev.yml up -d
Não instale o pacote com o override ativo

A tela de plugins apaga a pasta antes de extrair (removePluginByManifestIDremoveDir). Com o repositório montado ali, isso apaga o fonte.

Os testes rodam pelo CLI, dentro do container, com o Accelero carregado. Não há PHPUnit: cada arquivo é um roteiro que exercita o plugin de ponta a ponta contra um Accelero de verdade, e termina com TUDO OK ou com a lista do que falhou.

docker exec accelero_php_accelero php \\
/var/www/accelero/plugins/accelero_plugin_importador/tests/<arquivo>.php
TesteO que ele prova
e2e_local.phpO núcleo, sem job: lê a planilha, aplica a política, monta o relatório. Aceita o caminho de uma planilha e IMPORTADOR_ENTIDADE
e2e_job.phpO caminho assíncrono: cria o registro e executa o handler como o worker faria
e2e_blocos.phpA importação fatiada: contagens que não podem contar duas vezes o trecho refeito, ponto de retomada sempre avançando, relatório sem linha repetida, bloco morto retomado sozinho e importação travada encerrada com a linha em que emperrou
e2e_cancelamento.phpO cancelamento com a importação rodando de verdade, disparada pelo MQTT, com o pedido chegando em movimento
e2e_uma_por_vez.phpA segunda importação é recusada, e a mensagem diz qual ocupa a vez
e2e_tela.phpO contrato entre listagem e view: todo campo calculado tem célula, e o filtro cobre o enum inteiro
e2e_fotos.php / e2e_fotos_job.phpO lote de fotos, direto e pelo job
diag_modelos.php / diag_view.phpDiagnóstico, não teste: imprimem o modelo gerado e a view processada

Fixtures que não ficam no repositório (binário que ninguém lê num diff):

# fotos de teste (16 MB)
php tests/fixtures/gerar-fotos.php

# planilha de carga: quantidade, saída, % de linhas com erro, categoria, empresa
php tests/fixtures/gerar-pessoas.php 25000 /var/www/accelero/tmp/carga.xlsx 0 "ACESSO 24H"

O gerador de pessoas lê categorias e empresas do banco quando não recebe as duas últimas: vínculo inexistente recusa a linha inteira, e uma planilha de carga toda rejeitada não mede nada. Informe a categoria explicitamente para gerar uma planilha destinada a outro ambiente. Código e CPF levam um prefixo de lote nos dois campos — só no código não basta, porque o importador acha a pessoa por qualquer um dos dois e a carga viraria atualização sem avisar.


11. Pendências

Homologado em SaaS na 1.0.0: 25.000 pessoas importadas, 25.000 criadas, zero rejeitadas, em 1h53, com um bloco morto no meio e retomada automática até o fim. O que falta é rodagem, não funcionalidade.

  • Commit em lote com SAVEPOINT. É o que derruba o tempo em SaaS: hoje é uma transação, e portanto uma reconexão, por linha (ver "Por que a importação é rápida no local e lenta em SaaS"). Preservaria o relatório de rejeitados
  • Retomador num job periódico do core. Hoje ele roda quando alguém abre a tela do importador. No fluxo normal isso não importa, porque cada bloco dispara o seguinte; mas uma importação cujo bloco morreu fica parada até alguém olhar
  • Tela de acompanhamento com progresso ao vivo enquanto o job roda
  • Agendar a limpeza de uploads abandonados (ChunkedUploadStorage::purgeOlderThan)
  • Documentação pública do plugin