name: criar-modulo
description: Use ao criar novo módulo Laravel modular (nWidart) no oimpresso — qualquer pasta nova em Modules/<Nome>/, ou pedido explícito "criar módulo", "novo módulo", "scaffold módulo". Carrega checklist das 8 peças obrigatórias + 3 rotas admin Install (sem elas botão Install fica sem ação) + padrão Route::has() pra link público condicional + pegadinhas. Substitui leitura repetida de ADR 0011 + 0024 + receita ADS/Repair.
trust_level: L2
owner: wagner
parent_mission: meta-skill-roi-erp-autonomo
charter_adr: 0080
tier: B
parent_adr: 0095
Criar módulo Laravel no Oimpresso ERP
Quando ativa
- Pedido explícito: "criar módulo", "novo módulo", "scaffold um módulo X"
- Edit/Write em qualquer arquivo dentro de
Modules/<Nome novo>/ - Adição de entrada nova em
modules_statuses.json
Fonte canônica completa
memory/requisitos/Infra/RUNBOOK-criar-modulo.md — receita reproduzível com troubleshooting.
Checklist mínimo (não pular nenhum)
Módulo aparecer em /manage-modules com botão Install funcional + opcionalmente sidebar exige 8 peças:
| # | Arquivo | Por quê |
|---|---|---|
| 1 | module.json |
nWidart enxerga + provider list |
| 2 | composer.json |
psr-4 Modules\\<Nome>\\: "" |
| 3 | Config/config.php |
mergeConfigFrom + publishes |
| 4 | Providers/<Nome>ServiceProvider.php |
register Config + lang + RouteServiceProvider |
| 5 | Providers/RouteServiceProvider.php |
mapWebRoutes (e api se houver) |
| 6 | Http/Controllers/DataController.php |
3 hooks: superadmin_package + user_permissions + modifyAdminMenu |
| 7 | Http/Controllers/InstallController.php |
extends BaseModuleInstallController |
| 8 | Routes/web.php |
rotas do módulo + 3 rotas admin Install (§críticas) |
E mais 3 fora da pasta do módulo:
modules_statuses.json(raiz) — entrada"<Nome>": true- (Se rotas públicas) link condicional via
Route::has()em home_header.blade.php + auth2.blade.php + HandleInertiaRequests.phppublicRoutes+SiteHeader.tsx - ⚠️
phpunit.xml— quando criar a primeiraTests/Feature/*Test.phpdo módulo, adicionar<directory>./Modules/<Nome>/Tests/Feature</directory>(e Unit se houver) dentro da<testsuite name="Feature">. Esquecer = testes no repo mas CI nunca roda → falsa sensação de cobertura. Erro recorrente; vermemory/requisitos/Infra/RUNBOOK-pest-suite.md.
§Críticas — 3 rotas admin Install OBRIGATÓRIAS
Sem isso o botão Install na tela /manage-modules fica visível mas SEM AÇÃO (vai pra #). Install/ModulesController.php:57 usa action() que precisa de rota registrada apontando pro InstallController.
// Modules/<Nome>/Routes/web.php
use Modules\<Nome>\Http\Controllers\InstallController;
Route::middleware(['web', 'authh', 'auth', 'SetSessionData', 'language', 'timezone', 'AdminSidebarMenu'])
->prefix('<modulo-prefix>')
->group(function () {
Route::get('install', [InstallController::class, 'index']);
Route::get('install/uninstall', [InstallController::class, 'uninstall']);
Route::get('install/update', [InstallController::class, 'update']);
});
Vale mesmo se o módulo só expõe rotas públicas (caso ConsultaOs).
Link público condicional (Route::has())
Se o módulo expõe rota pública (ex: /consulta-os, /repair-status) que deve aparecer no header do CMS APENAS quando o módulo está ativo:
Blade legado: adicionar em home_header.blade.php + auth2.blade.php:
@if(Route::has('<rota-nomeada>'))
<li><a href="{{ route('<rota-nomeada>') }}">Acompanhar pedido</a></li>
@endif
Inertia: adicionar flag em HandleInertiaRequests::share() chave publicRoutes e ler em SiteHeader.tsx via usePage().props.publicRoutes. Quando módulo é desativado em /manage-modules, a rota some, Route::has() vira false, link some.
Referências canônicas pra imitar
| Caso | Imitar |
|---|---|
| Só rotas públicas + Install routes | Modules/ConsultaOs/ (validado 2026-05-04) |
| Sidebar admin completa + service singletons | Modules/ADS/ (validado 2026-05-03) |
| CRUD multi-tenant | Modules/Repair/, Modules/Project/, Modules/Jana/ |
| Spec-driven | Modules/NFSe/ |
Pegadinhas críticas
- ❌ NÃO usar
__('alias::file.key')em DataController/topnav —LegacyMenuAdapterlê literal, não resolve traduções → labels saem crus em prod. Hardcodar PT-BR (NFSe sempre fez assim). - ❌ NÃO usar
route('xxx.yyy')em Pages React — Ziggy não está disponível. Usar template literal:href={`/<prefix>/admin/${id}`}. - ❌ NÃO esquecer das rotas admin Install se o módulo tem só rotas públicas — botão Install fica sem ação.
- ❌ NÃO rodar
npm run build(config errado) — semprenpm run build:inertiapra gerar Pages no manifest. - ❌ NÃO esquecer de rodar
composer installno Hostinger pós-deploy se mexeu emcomposer.json/lock— sintoma: tela branca Inertia (null.component).
⚠️ Erros frequentes em DataController (pattern UltimatePOS exige formato exato)
superadmin_package — DEVE retornar array de arrays com name field, NÃO array com keys string:
// ❌ ERRADO — quebra com "Undefined array key 0" em get_module_names()
public function superadmin_package() {
return [
'meu_modulo' => [
'label' => '...',
'default' => false,
],
];
}
// ✅ CERTO
public function superadmin_package() {
return [
[
'name' => 'meu_modulo',
'label' => '...',
'default' => false,
],
];
}
Por quê: Modules/Accounting/Helpers/general_helper.php:303 faz $permission[0]['name'] — se você passou key string, $permission não tem índice 0.
Middleware stack das rotas admin — pattern canônico tem 'authh' (com duplo h) + 'SetSessionData':
// ✅ CERTO (skill criar-modulo §Críticas)
Route::middleware(['web', 'authh', 'auth', 'SetSessionData', 'language', 'timezone', 'AdminSidebarMenu', 'CheckUserLogin'])
->prefix('meu-modulo')
->group(function () { ... });
Rotas Install usam index (não install) e URL install (não install/install):
// ✅ CERTO
Route::get('install', [InstallController::class, 'index']);
Route::get('install/uninstall', [InstallController::class, 'uninstall']);
Route::get('install/update', [InstallController::class, 'update']);
moduleSystemKey() é lowercase SEM hífen — DEVE bater com strtolower($moduleName):
// ❌ ERRADO — install grava chave kebab-case no `system` table
protected function moduleName(): string { return 'OficinaAuto'; }
protected function moduleSystemKey(): string { return 'oficina-auto'; } // ← kebab
// ✅ CERTO
protected function moduleSystemKey(): string { return 'oficinaauto'; } // ← lowercase sem hífen
Por quê: app/Utils/ModuleUtil.php::isModuleInstalled() faz System::getProperty(strtolower($module_name).'_version') — busca oficinaauto_version, NÃO oficina-auto_version. Se moduleSystemKey for kebab, install grava chave errada → isModuleInstalled() sempre false → DataController nunca rodado → sidebar não monta item → tela /manage-modules mostra "Instalar" perpetuamente.
Bug real catalogado 2026-05-13: OficinaAuto + ComunicacaoVisual. Pest tests/Feature/Modules/InstallControllerKeyConventionTest.php agora trava CI.
⚠️ Schemas DB que controllers acessam — VERIFICAR antes de escrever query
Erros comuns (não chute schema):
mcp_memory_documents— tem colunastatusdireta (varchar20), nãofrontmatter_json LIKE '%"status"%'mcp_audit_log— usatscomo timestamp canonical (não sócreated_at); endpoint é ENUM(7 valores: tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get, initialize)mcp_skill_approvals— registradecision(approve/reject/request_changes), nãostatus. "Pending" semanticamente =mcp_skill_versions.status='review'mcp_alertas— temkind(enum 5 valores: cota_excedida/tool_destrutiva/ip_suspeito/taxa_errors/cliente_externo), NÃOcategory/severity/module/detailmcp_governance_rules.category— enum (promotion/archival/escalation/retry/budget/review)
Sempre rodar DESCRIBE <tabela> antes de escrever query nova:
ssh -4 -i ~/.ssh/id_ed25519_oimpresso -p 65002 u906587222@148.135.133.115 \
'cd ~/domains/oimpresso.com/public_html && PASS=$(grep "^DB_PASSWORD=" .env | cut -d= -f2- | tr -d "\"") && \
mysql -u u906587222_oimpresso -p"$PASS" u906587222_oimpresso -e "DESCRIBE <tabela>;"'
⚠️ Translations: pasta pt/ (não pt-BR/) é o pattern UltimatePOS
Modules/<Nome>/Resources/lang/
├── pt/
│ └── <alias>.php
└── en/
└── <alias>.php
KB tem ambos pt/ e pt-BR/ por histórico, mas TeamMcp/ADS/NFSe canonical é só pt/ + en/.
ServiceProvider.registerTranslations() carrega __DIR__ . '/../Resources/lang' — Laravel resolve por locale automático.
⚠️ Lição de aprendizado registrada
Erro real cometido em 2026-05-06 ao criar Modules/Governance:
- ❌ Não invoquei skill
criar-moduloantes de criar — perdi 4 round-trips de bugfix - ❌
superadmin_packageformato errado (key string em vez denamefield) — Wagner viu 'Undefined array key 0' - ❌ Middleware sem
authh+SetSessionData - ❌ Rotas Install com
install/installem vez de sóinstall, actioninstallem vez deindex - ❌ Queries DB com colunas inventadas (
frontmatter_json,mcp_alertas.category,mcp_skill_approvals.status) - ❌ Translations só em
pt-BR/— pattern canonical épt/+en/ - ❌
moduleSystemKey()em kebab-case ('oficina-auto') — system table grava chave errada,isModuleInstalled()sempre false, sidebar nunca monta item. Catalogado 2026-05-13 (OficinaAuto + ComunicacaoVisual em prod). - ❌ Módulo mergeado mas nunca ATIVADO em runtime —
modules_statuses.jsonsem entrada → nWidart marca[Disabled]→RouteServiceProvider+DataController+InstallControllernunca executam → bugs latentes invisíveis em CI. Habilitar tempos depois dispara cascata de fatais. Catalogado 2026-05-13: Auditoria merged em PR #474 (semanas antes), só apareceu em prod 4 bugs em sequência ao habilitar (PRs #750→#751→#752→#756→#760).
⚠️ Pegadinha #8 — ativar e fumigar ANTES de merge
CI verde NÃO valida módulo Disabled. O Laravel-modules nWidart só registra providers/rotas/menu de módulos [Enabled]. Se você cria um módulo sem entrada em modules_statuses.json (ou com "<Nome>": false), TUDO no Modules/<Nome>/Providers/, DataController, InstallController, Routes/web.php permanece código morto até alguém ativar — e bugs latentes (typo de namespace, método abstract não implementado, API errada de MenuBuilder etc) passam imunes.
Antídoto antes do merge:
# 1) Garantir entrada em modules_statuses.json
grep -E "\"<Nome>\"\s*:\s*true" modules_statuses.json || echo "FALTA"
# 2) Validar boot real do módulo (catch fatal sem precisar de Pest)
php artisan module:list | grep <Nome> # deve mostrar [Enabled]
php artisan route:list --path=<prefix>/install # 3 rotas
php artisan route:list --path=<prefix> # rotas do módulo
# 3) Smoke runtime mínimo: render sidebar (executa DataController::modifyAdminMenu)
php artisan tinker --execute="
Auth::loginUsingId(1);
app('Illuminate\Routing\Router')->dispatch(
Illuminate\Http\Request::create('/home', 'GET')
);
echo 'OK';
"
Se algum passo lança fatal, fix ANTES do merge — economiza N PRs de hotfix em cascata.
Antídoto: PRIMEIRO comando ao iniciar criação de módulo: invocar skill criar-modulo via tool Skill. Antes de escrever 1 linha de código novo em Modules/<Nome>/.
Validação local antes de comitar
# 1. PHP lint
php -l Modules/<Nome>/Http/Controllers/InstallController.php
php -l Modules/<Nome>/Routes/web.php
# 2. Rota Install resolvida
php artisan route:list --path=<prefix>/install
# → 3 linhas (index, uninstall, update). Se menos, action() vai pra #.
# 3. Bundle Inertia (se mexeu em Pages/Components React)
npm run build:inertia
grep -i "Pages/<Nome>" public/build-inertia/manifest.json
Deploy Hostinger pós-merge
ssh -4 -i ~/.ssh/id_ed25519_oimpresso -p 65002 u906587222@148.135.133.115 \
'cd ~/domains/oimpresso.com/public_html && git pull && composer dump-autoload --no-scripts && php artisan cache:clear && php artisan view:clear'
Depois login superadmin → /manage-modules → clicar Install no card do módulo.