Aceita fila administrativa de associacoes com checkpoint local

This commit is contained in:
2026-11-02 11:20:13 +02:00
parent fcf0ada227
commit 340f3bb5fa
15 changed files with 635 additions and 67 deletions
+2
View File
@@ -1,5 +1,7 @@
# SGF TEMPO # SGF TEMPO
Fila administrativa de associações: **Opções gerais → Associações de módulos**, com filtros/paginação, prévia de impacto e confirmação individual. [Implementação local, testes e limitações](docs/MODULE_ASSOCIATION_QUEUE_DELIVERY.md). Sem atribuição automática; os cinco pendentes locais e o cliente sintético 8039 permanecem preservados. Revisão visual separada.
**Obrigações a fornecedores — ensaio:** SGF em http://127.0.0.1:8006 e Compras autónomo em http://127.0.0.1:8005/obrigacoes-fornecedores. [Guião, funções, código e provas](docs/SUPPLIER_PAYABLE_DELIVERY.md). Principal 11 600 MZN, vencimento pendente; sem pagamentos ou uso real. A demonstração orçamental em 8003 permanece independente. **Obrigações a fornecedores — ensaio:** SGF em http://127.0.0.1:8006 e Compras autónomo em http://127.0.0.1:8005/obrigacoes-fornecedores. [Guião, funções, código e provas](docs/SUPPLIER_PAYABLE_DELIVERY.md). Principal 11 600 MZN, vencimento pendente; sem pagamentos ou uso real. A demonstração orçamental em 8003 permanece independente.
**Percurso orçamental sintético disponível:** http://127.0.0.1:8003 → seleccionar empresa/unidade → **Compras → Controlo orçamental sintético**. [Contas por função, guião, arranque e provas](docs/SGF_BUDGET_WORKSPACE_DELIVERY.md). Interface ligada ao núcleo comum; base de equipa separada da demonstração anterior em 8002. Apenas QA, sem habilitação real ou aceitação visual presumida. **Percurso orçamental sintético disponível:** http://127.0.0.1:8003 → seleccionar empresa/unidade → **Compras → Controlo orçamental sintético**. [Contas por função, guião, arranque e provas](docs/SGF_BUDGET_WORKSPACE_DELIVERY.md). Interface ligada ao núcleo comum; base de equipa separada da demonstração anterior em 8002. Apenas QA, sem habilitação real ou aceitação visual presumida.
@@ -1,55 +1,10 @@
<?php <?php
namespace App\Modules\Identity\Http; namespace App\Modules\Identity\Http;
use App\Modules\Identity\Services\ModuleAssociationReview;
use App\Shared\Context\{CompanyContext,ModuleNavigation,ModuleRecords};
use App\Modules\Identity\Services\Permissions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class ModuleRecordsController final class ModuleRecordsController
{ {
public function index(Request $r,CompanyContext $context,Permissions $permissions) public function index(Request $request, ModuleAssociationReview $review) { return response()->json($review->listing($request))->header('Cache-Control', 'no-store'); }
{ public function preview(Request $request, ModuleAssociationReview $review) { return response()->json($review->preview($request))->header('Cache-Control', 'no-store'); }
$s=$context->identifiers();$out=[]; public function associate(Request $request, ModuleAssociationReview $review) { return response()->json($review->confirm($request))->header('Cache-Control', 'no-store'); }
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],'access.manage');
foreach(ModuleRecords::AREAS as $area){
$unit=str_ends_with($area,'_drafts')?$context->unitId():null;
if(str_ends_with($area,'_drafts')&&!$unit)continue;
if(!$permissions->has($context->userId(),$s['tenant_id'],$s['company_id'],"$area.view",$unit))continue;
$rows=DB::table('a_'.$area)->where($s)->where('unit_id',$unit)->orderBy('id')->get(['id','name','version']);
foreach($rows as $row){
$row->modules=DB::table('identity_audit')->where($s)->whereIn('action',['module.record.associated','module.record.disassociated'])
->whereRaw("details::jsonb->>'area' = ?",[$area])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$row->id])
->orderByDesc('id')->get(['details','action'])->unique(fn($a)=>json_decode($a->details,true)['module_id'])
->filter(fn($a)=>$a->action==='module.record.associated')->map(fn($a)=>json_decode($a->details,true)['module_id'])->values()->all();
}
$out[$area]=$rows;
}
return response()->json(['records'=>$out,'modules'=>app(ModuleNavigation::class)->available($context),
'notice'=>'Registos sem associação explícita permanecem aqui visíveis pelos direitos originais. Associar não altera o registo, a origem ou as permissões.'])->header('Cache-Control','no-store');
}
public function associate(Request $r,CompanyContext $context,Permissions $permissions)
{
$d=$r->validate(['area'=>'required|in:'.implode(',',ModuleRecords::AREAS),'id'=>'required|integer|min:1','version'=>'required|integer|min:1','module_id'=>'required|string','reason'=>'required|string|min:3|max:500','operation'=>'sometimes|in:associate,remove']);
abort_if(array_diff(array_keys($r->all()),array_keys($d))!==[],422);
$s=$context->identifiers();$unit=str_ends_with($d['area'],'_drafts')?$context->unitId():null;
abort_if(str_ends_with($d['area'],'_drafts')&&!$unit,403);
DB::transaction(function()use($d,$s,$unit,$context,$permissions){
DB::table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],'access.manage');
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$d['area'].'.view',$unit);
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$d['area'].'.edit',$unit);
abort_unless(in_array($d['module_id'],array_column(app(ModuleNavigation::class)->available($context),'id'),true),403);
$row=DB::table('a_'.$d['area'])->where($s)->where('unit_id',$unit)->where('id',$d['id'])->first(['id','version']);
abort_unless($row,403);
abort_unless((int)$row->version===(int)$d['version'],409,'O registo mudou. Reabra a consulta antes de decidir a associação.');
$previous=DB::table('identity_audit')->where($s)->whereIn('action',['module.record.associated','module.record.disassociated'])
->whereRaw("details::jsonb->>'area' = ?",[$d['area']])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$d['id']])->whereRaw("details::jsonb->>'module_id' = ?",[$d['module_id']])->orderByDesc('id')->value('action');
$remove=($d['operation']??'associate')==='remove';
if(($remove&&$previous==='module.record.associated')||(!$remove&&$previous!=='module.record.associated'))
ModuleRecords::associate($d['area'],$d['id'],$context->userId(),$s+['unit_id'=>$unit],$d['module_id'],$d['reason'],$remove,(int)$row->version);
});
return response()->json(['ok'=>true]);
}
} }
+1 -1
View File
@@ -9,7 +9,7 @@ final class Audit
public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void
{ {
// Explicit allowlist: credentials, session/context tokens and request bodies never enter audit. // Explicit allowlist: credentials, session/context tokens and request bodies never enter audit.
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id', 'module_id', 'previous_module_id'])); $safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id', 'module_id', 'previous_module_id', 'decision_id', 'review_hash', 'before_modules', 'after_modules', 'before_revision']));
DB::table('identity_audit')->insert([ DB::table('identity_audit')->insert([
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant, 'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject, 'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
@@ -0,0 +1,211 @@
<?php
namespace App\Modules\Identity\Services;
use App\Shared\Context\{CompanyContext, ModuleNavigation, ModuleRecords};
use Illuminate\Support\Facades\{Crypt, DB};
use Illuminate\Http\Request;
use Illuminate\Support\Str;
final class ModuleAssociationReview
{
public function __construct(private CompanyContext $context, private Permissions $permissions) {}
private function scope(string $area, bool $edit = false): array
{
$s = $this->context->identifiers();
$unit = str_ends_with($area, '_drafts') ? $this->context->unitId() : null;
abort_if(str_ends_with($area, '_drafts') && !$unit, 403);
foreach (['access.manage', "$area.view", ...($edit ? ["$area.edit"] : [])] as $right) {
$this->permissions->require($this->context->userId(), $s['tenant_id'], $s['company_id'], $right, $right === 'access.manage' ? null : $unit);
}
return $s + ['unit_id' => $unit];
}
private function events(string $area, int $id)
{
return DB::table('identity_audit')->where($this->context->identifiers())
->whereIn('action', ['module.record.associated', 'module.record.disassociated', 'module.record.reviewed'])
->whereRaw("details::jsonb->>'area' = ?", [$area])->whereRaw("details::jsonb->>'record_id' = ?", [(string) $id]);
}
private function links(string $area, int $id): array
{
$events = $this->events($area, $id)->where('action', '<>', 'module.record.reviewed')
->selectRaw("DISTINCT ON (details::jsonb->>'module_id') action,details")
->orderByRaw("details::jsonb->>'module_id'")->orderByDesc('id')->get();
$modules = $events
->filter(fn ($e) => $e->action === 'module.record.associated')
->map(fn ($e) => json_decode($e->details, true)['module_id'])->sort()->values()->all();
return ['modules' => $modules, 'revision' => (int) ($this->events($area, $id)->max('id') ?? 0)];
}
private function row(string $area, int $id, bool $edit = false): object
{
$row = DB::table('a_'.$area)->where($this->scope($area, $edit))->where('id', $id)->first();
abort_unless($row, 403, 'Registo ou dependência fora do âmbito autorizado.');
return $row;
}
// Traverse explicit references, never infer a destination or grant access.
private function graph(string $area, int $id, array &$seen): void
{
$key = "$area:$id";
if (isset($seen[$key])) return;
$row = $this->row($area, $id);
$links = $this->links($area, $id);
$seen[$key] = ['area' => $area, 'id' => $id, 'version' => (int) $row->version,
'checksum' => hash('sha256', json_encode((array) $row, JSON_THROW_ON_ERROR)), ...$links];
$data = json_decode($row->data, true, 512, JSON_THROW_ON_ERROR);
if (isset($row->client_id)) $this->graph('clients', (int) $row->client_id, $seen);
if (isset($row->source_id)) $this->graph('service_drafts', (int) $row->source_id, $seen);
foreach (str_ends_with($area, '_drafts') ? ($data['lines'] ?? []) : [] as $line) {
if (isset($line['service_id'])) $this->graph('services', (int) $line['service_id'], $seen);
}
}
private function snapshot(string $area, int $id): array
{
$graph = [];
$this->graph($area, $id, $graph);
ksort($graph);
$rights = DB::table('permission_grants')->where($this->context->identifiers())->where('user_id', $this->context->userId())->orderBy('id')->get();
return ['graph' => $graph, 'modules' => app(ModuleNavigation::class)->available($this->context),
'permissions' => hash('sha256', $rights->toJson())];
}
private function digest(array $value): string { return hash('sha256', json_encode($value, JSON_THROW_ON_ERROR)); }
public function listing(Request $r): array
{
abort_unless(hash_equals((string) $r->session()->get('company_context.token'), (string) $r->header('X-SGF-Context')), 409, 'O contexto mudou. Reabra a fila.');
$d = $r->validate(['area' => 'sometimes|in:'.implode(',', ModuleRecords::AREAS), 'state' => 'sometimes|in:all,pending,associated,removed',
'page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|min:1|max:100', 'search' => 'nullable|string|max:100',
'unit_id' => 'sometimes|integer|min:1']);
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
$area = $d['area'] ?? 'clients';
$scope = $this->scope($area);
if (isset($d['unit_id'])) abort_unless((int) $d['unit_id'] === $this->context->unitId(), 403);
$query = DB::table('a_'.$area)->where($scope);
$active = "EXISTS (SELECT 1 FROM identity_audit ma WHERE ma.tenant_id=a_{$area}.tenant_id AND ma.company_id=a_{$area}.company_id
AND ma.action='module.record.associated' AND ma.details::jsonb->>'area'=? AND ma.details::jsonb->>'record_id'=a_{$area}.id::text
AND NOT EXISTS (SELECT 1 FROM identity_audit mr WHERE mr.tenant_id=ma.tenant_id AND mr.company_id=ma.company_id AND mr.id>ma.id
AND mr.action='module.record.disassociated' AND mr.details::jsonb->>'area'=ma.details::jsonb->>'area'
AND mr.details::jsonb->>'record_id'=ma.details::jsonb->>'record_id' AND mr.details::jsonb->>'module_id'=ma.details::jsonb->>'module_id'))";
$ever = "EXISTS (SELECT 1 FROM identity_audit me WHERE me.tenant_id=a_{$area}.tenant_id AND me.company_id=a_{$area}.company_id
AND me.action IN ('module.record.associated','module.record.disassociated') AND me.details::jsonb->>'area'=? AND me.details::jsonb->>'record_id'=a_{$area}.id::text)";
match ($d['state'] ?? 'all') {
'associated' => $query->whereRaw($active, [$area]),
'pending' => $query->whereRaw("NOT $ever", [$area]),
'removed' => $query->whereRaw("NOT $active AND $ever", [$area, $area]),
default => null,
};
if (($d['search'] ?? '') !== '') $query->where('name', 'ilike', '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $d['search']).'%');
$page = $query->orderBy('id')->paginate($d['per_page'] ?? 25, ['id', 'name', 'version'], 'page', $d['page'] ?? 1);
$rows = [];
foreach ($page->items() as $row) {
$links = $this->links($area, $row->id);
$deps = []; $restricted = false;
try {
$graph = []; $this->graph($area, $row->id, $graph);
unset($graph["$area:$row->id"]);
$deps = array_values(array_map(fn ($g) => array_diff_key($g, ['checksum' => true]), $graph));
} catch (\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException $e) { $restricted = true;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $restricted = true; }
$rows[] = [...(array) $row, ...$links, 'dependencies' => $deps, 'dependencies_restricted' => $restricted];
}
return ['records' => [$area => $rows], 'modules' => app(ModuleNavigation::class)->available($this->context),
'pagination' => ['page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
'notice' => 'Cada decisão exige conferência individual. Dependências não são associadas automaticamente.'];
}
public function preview(Request $r): array
{
$d = $this->input($r);
return DB::transaction(function () use ($d, $r) {
$s = $this->context->identifiers();
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($this->context->userId(), $s['tenant_id'], $s['company_id'], $this->context->unitId());
$row = $this->row($d['area'], $d['id'], true);
abort_unless((int) $row->version === (int) $d['version'], 409, 'O registo mudou. Faça nova conferência.');
$snapshot = $this->snapshot($d['area'], $d['id']);
abort_unless(in_array($d['module_id'], array_column($snapshot['modules'], 'id'), true), 403);
$before = $snapshot['graph'][$d['area'].':'.$d['id']]['modules'];
$after = $this->after($before, $d);
$dependencies = $snapshot['graph'];
unset($dependencies[$d['area'].':'.$d['id']]);
$missing = array_values(array_filter($dependencies, fn ($g) => !in_array($d['module_id'], $g['modules'], true)));
$payload = ['decision_id' => (string) Str::uuid(), 'actor' => $this->context->userId(), 'scope' => $this->context->identifiers(),
'unit' => $this->context->unitId(), 'context' => hash('sha256', $r->header('X-SGF-Context')), 'expires' => time() + 900,
'input' => $d, 'review_hash' => $this->digest($snapshot)];
return ['review_token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)), 'record' => ['area' => $d['area'], 'id' => $row->id, 'name' => $row->name, 'version' => $row->version],
'before' => $before, 'after' => $after, 'dependencies' => array_values(array_map(fn ($g) => array_diff_key($g, ['checksum' => true]), $snapshot['graph'])),
'reason' => $d['reason'], 'operation' => $d['operation'], 'module_id' => $d['module_id'],
'warnings' => [$d['operation'] === 'associate'
? count($missing).' dependências sem associação ao destino escolhido. Continuam pendentes; esta decisão não as disponibiliza nesse módulo.'
: 'A retirada pode impedir a consulta deste registo e a sua selecção como origem nesse módulo. Registos que o referenciam e documentos anteriores permanecem preservados.'],
'history' => $this->events($d['area'], $d['id'])->orderByDesc('id')->limit(20)->get(['id', 'action', 'actor_id', 'created_at', 'details']),
'impact' => 'Altera apenas a associação deste registo. Não altera direitos, dependências, origens ou responsabilidade financeira. A visibilidade continua sujeita aos direitos originais.'];
});
}
private function input(Request $r): array
{
$d = $r->validate(['area' => 'required|in:'.implode(',', ModuleRecords::AREAS), 'id' => 'required|integer|min:1', 'version' => 'required|integer|min:1',
'module_id' => 'required|string|max:50', 'reason' => 'required|string|min:3|max:500', 'operation' => 'required|in:associate,remove']);
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
abort_if(mb_strlen(trim($d['reason'])) < 3, 422);
return $d;
}
private function after(array $before, array $d): array
{
$after = $d['operation'] === 'remove' ? array_values(array_diff($before, [$d['module_id']])) : array_values(array_unique([...$before, $d['module_id']]));
sort($after); return $after;
}
public function confirm(Request $r): array
{
$d = $r->validate(['review_token' => 'required|string|max:30000']);
abort_if(array_keys($r->all()) !== ['review_token'], 422);
try { $p = json_decode(Crypt::decryptString($d['review_token']), true, 512, JSON_THROW_ON_ERROR); }
catch (\Throwable) { abort(422, 'Conferência inválida.'); }
abort_unless(is_array($p) && isset($p['actor'], $p['scope'], $p['context'], $p['expires'], $p['input'], $p['review_hash'], $p['decision_id'])
&& array_key_exists('unit', $p), 422, 'Conferência inválida.');
abort_unless($p['actor'] === $this->context->userId() && $p['scope'] === $this->context->identifiers() && $p['unit'] === $this->context->unitId()
&& hash_equals($p['context'], hash('sha256', $r->header('X-SGF-Context'))), 409, 'O contexto mudou. Faça nova conferência.');
abort_if($p['expires'] < time(), 409, 'Conferência expirada.');
return DB::transaction(function () use ($p) {
$d = $p['input'];
$s = $this->context->identifiers();
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($this->context->userId(), $s['tenant_id'], $s['company_id'], $this->context->unitId());
// Existing identity administration also serializes through this company.
$this->row($d['area'], $d['id'], true);
$initial = $this->snapshot($d['area'], $d['id']);
foreach ($initial['graph'] as $g) {
DB::table('a_'.$g['area'])->where($this->scope($g['area']))->where('id', $g['id'])->lockForUpdate()->firstOrFail();
}
$current = $this->snapshot($d['area'], $d['id']);
abort_unless($this->digest($initial) === $this->digest($current), 409, 'As dependências mudaram. Faça nova conferência.');
abort_unless(in_array($d['module_id'], array_column($current['modules'], 'id'), true), 403);
$receipt = $this->events($d['area'], $d['id'])->whereRaw("details::jsonb->>'decision_id' = ?", [$p['decision_id']])->first();
$key = $d['area'].':'.$d['id'];
if ($receipt) {
$details = json_decode($receipt->details, true);
abort_unless($current['graph'][$key]['revision'] === (int) $receipt->id && $current['graph'][$key]['modules'] === $details['after_modules'], 409, 'A associação mudou. Faça nova conferência.');
$current['graph'][$key]['revision'] = $details['before_revision'];
$current['graph'][$key]['modules'] = $details['before_modules'];
abort_unless(hash_equals($p['review_hash'], $this->digest($current)), 409, 'O registo ou dependências mudaram. Faça nova conferência.');
return ['ok' => true, 'replayed' => true, 'event_id' => $receipt->id];
}
abort_unless(hash_equals($p['review_hash'], $this->digest($current)), 409, 'O registo, permissões ou dependências mudaram. Faça nova conferência.');
$before = $current['graph'][$key]['modules']; $after = $this->after($before, $d);
$action = $before === $after ? 'module.record.reviewed' : ($d['operation'] === 'remove' ? 'module.record.disassociated' : 'module.record.associated');
Audit::record($action, $this->context->userId(), $p['scope']['tenant_id'], $p['scope']['company_id'], str_ends_with($d['area'], '_drafts') ? $p['unit'] : null,
details: ['area' => $d['area'], 'record_id' => $d['id'], 'version' => $d['version'], 'module_id' => $d['module_id'], 'reason' => $d['reason'],
'decision_id' => $p['decision_id'], 'review_hash' => $p['review_hash'], 'before_modules' => $before, 'after_modules' => $after, 'before_revision' => $current['graph'][$key]['revision']]);
return ['ok' => true, 'replayed' => false, 'event_id' => $this->events($d['area'], $d['id'])->max('id')];
}, 3);
}
}
+4
View File
@@ -1,5 +1,9 @@
# Navegação modular — regressão e perfis de desenvolvimento # Navegação modular — regressão e perfis de desenvolvimento
Incremento local posterior: [fila administrativa de conferência](MODULE_ASSOCIATION_QUEUE_DELIVERY.md), com filtros/paginação, prévia obrigatória, decisão individual e provas sintéticas isoladas. Não altera os pendentes protegidos nem constitui aceitação visual ou implantação em 8039.
Fila aceite em 02/11/2026, com autorização de checkpoint local de código funcional/testes/documentação; ver [aceitação](MODULE_ASSOCIATION_QUEUE_ACCEPTANCE.md). Revisão visual, carga de implantação, seis registos sem associação e cache bloqueada permanecem separados e preservados.
**Aceitação técnica expressa em 02/11/2026**, registada no [relatório de entrega](MODULAR_NAVIGATION_DELIVERY.md), com autorização de checkpoint exclusivamente local e limitado à entrega. Revisão visual, associações antigas pendentes e volume/paginação permanecem separados. Não autoriza publicação, direitos públicos novos ou activação de candidatas; Compras mantém-se em **0.1.1**. **Aceitação técnica expressa em 02/11/2026**, registada no [relatório de entrega](MODULAR_NAVIGATION_DELIVERY.md), com autorização de checkpoint exclusivamente local e limitado à entrega. Revisão visual, associações antigas pendentes e volume/paginação permanecem separados. Não autoriza publicação, direitos públicos novos ou activação de candidatas; Compras mantém-se em **0.1.1**.
Actualização de 01/11/2026 à [entrega inicial](MODULAR_NAVIGATION_DELIVERY.md). Concluídas as duas frentes técnicas: regressão da suite existente e configuração de perfis sintéticos num ambiente de desenvolvimento separado. A [revisão visual](MODULAR_NAVIGATION_VISUAL_REVIEW.md) continua pendente e separada. Actualização de 01/11/2026 à [entrega inicial](MODULAR_NAVIGATION_DELIVERY.md). Concluídas as duas frentes técnicas: regressão da suite existente e configuração de perfis sintéticos num ambiente de desenvolvimento separado. A [revisão visual](MODULAR_NAVIGATION_VISUAL_REVIEW.md) continua pendente e separada.
@@ -0,0 +1,15 @@
# Aceitação da fila administrativa de associações
Em **02/11/2026**, o utilizador determinou: «Regista a aceitação da fila de associações e cria um checkpoint local apenas desta entrega, preservando alterações alheias e excluindo credenciais e dados privados.»
Fica aceite a implementação local descrita em [entrega e limitações](MODULE_ASSOCIATION_QUEUE_DELIVERY.md): filtros/paginação no servidor, isolamento, prévia de impacto, decisão individual fundamentada, revalidação de contexto/permissões/versões/dependências, histórico append-only, concorrência, repetição e recuperação transaccional.
O checkpoint inclui **código funcional**, interface, rotas, testes, verificações de apresentação e documentação/provas resumidas desta entrega. Não é um checkpoint apenas documental. Não inclui configuração privada, credenciais, dumps, sessões, tokens concretos, documentos empresariais, inventários linha a linha dos registos locais ou alterações em curso de Compras. O levantamento anterior e os respectivos ficheiros locais permanecem fora deste checkpoint; não são apagados.
São reutilizados os resultados válidos já registados: 17 testes/221 asserções da fila/navegação, 25 testes/110 asserções de identidade/isolamento/guardas, concorrência com dois processos, recuperação, build e verificações de apresentação. **Não se repetiram testes, build ou consultas aos dados para esta aceitação.** A conferência desta passagem limita-se aos ficheiros e ao conteúdo do checkpoint.
Permanecem pendentes revisão visual/interacção/recargas e avaliação de carga de implantação. O ensaio de 137 registos não constitui prova de carga. Mantêm-se os restantes limites do relatório, incluindo histórico recente de 20 eventos e validade de 15 minutos das prévias.
Os cinco registos pendentes do SGF local e o cliente sintético pendente de 8039 permanecem sem associação, dependentes de decisões individuais posteriores. Não se executam decisões, concessões, migrações, reinícios ou implantação na demonstração por consequência desta aceitação. Cache bloqueada mantida intacta, sem nova tentativa de limpeza. Não há publicação, push, activação de Compras ou alterações de direitos públicos.
A aceitação e o checkpoint são exclusivamente locais. O identificador Git é devolvido após a criação; este documento integra o próprio checkpoint.
+66
View File
@@ -0,0 +1,66 @@
# Fila administrativa de conferência de associações
Implementação local de 02/11/2026, autorizada após o levantamento local `MODULE_ASSOCIATIONS_REVIEW.md`. Reutiliza **Opções gerais → Associações de módulos**, contexto e auditoria append-only existentes. Sem novos direitos, concessões, migração, publicação ou activação de candidatas de Compras. Não foi instalada por cópia na demonstração 8039.
**Aceite pelo utilizador em 02/11/2026**, com checkpoint exclusivamente local de código funcional, testes e documentação. Ver [aceitação e limites preservados](MODULE_ASSOCIATION_QUEUE_ACCEPTANCE.md). Revisão visual e carga de implantação continuam pendentes.
## Percurso entregue
A fila permite seleccionar área, estado (sem eventos de associação / com associação activa / todas as associações retiradas / todos) e procurar um nome literal. Rascunhos pertencem à unidade activa; mudar de unidade exige seleccionar outro contexto autorizado. Clientes/serviços continuam empresariais. O servidor valida a unidade quando enviada e recusa sobreposição de empresa/tenant por parâmetros.
Paginação no servidor, ordenada por ID: 25 registos por página na interface, limite de 100 na API. Cada registo apresenta nome, ID, versão, associações, módulos disponíveis e dependências explícitas: cliente, serviços das linhas e prestação de origem, transitivamente neste recorte de quatro áreas. Sem consulta original de uma dependência, a fila mostra aviso sem divulgar os seus identificadores; a prévia é recusada.
O operador escolhe individualmente operação, módulo e fundamento. **Conferir impacto** não escreve associações: apresenta antes/depois, versão, fundamento, dependências e histórico recente. A prévia avisa quando faltam associações das dependências ao destino escolhido e explica o impacto da retirada. Não escolhe módulo por defeito nem resolve automaticamente as dependências. Uma associação individual não declara os documentos operacionalmente completos.
Só **Confirmar esta decisão individual** grava. Alterar o formulário, filtros ou contexto descarta a prévia na interface. Respostas tardias da listagem não substituem resultados de outro filtro/contexto. Em falha de confirmação, pode repetir-se o mesmo token; mudanças detectadas pelo servidor exigem cancelar e conferir novamente.
## Contrato e garantias
| Percurso | Comportamento |
| --- | --- |
| `GET /api/module-records` | Geração actual `X-SGF-Context`, `access.manage`, consulta original; filtros/paginação validados; `no-store`. |
| `POST /api/module-records/preview` | Direitos anteriores mais edição original, âmbito exacto, versão, fundamento e disponibilidade do módulo. Devolve impacto/token cifrado e autenticado, sem persistir decisões. |
| `POST /api/module-records` | Aceita apenas `review_token`. O antigo POST directo é recusado; confirmação não pode trocar a decisão da prévia. |
O token vincula autor, tenant/empresa/unidade e geração de contexto, com validade de 15 minutos. O resumo inclui conteúdo/versão do registo, referências, conteúdo/versões/associações das dependências, revisões do histórico, permissões do autor e módulos disponíveis. Conteúdos são representados por hashes; documentos e credenciais não entram na auditoria/token.
Na confirmação, uma transacção bloqueia a empresa pelo mecanismo administrativo existente, volta a seleccionar o contexto e conferir pertença/permissões. Descobre dependências, bloqueia os registos por ordem estável, relê o conjunto e compara a fotografia. Mudanças de versão, conteúdo, origem, associação, direitos ou módulos recusam a decisão. Deadlocks têm até três tentativas, sem apagar versões ou histórico.
O único efeito persistente é um evento na auditoria existente, na mesma transacção: autor, data, fundamento, área/ID/versão/módulo, decisão única, resumo conferido e conjuntos anterior/posterior. Associação/retirada usa os eventos já reconhecidos pelo isolamento. Uma decisão sem mudança regista `module.record.reviewed`, sem inventar associação. Originais, versões, fotografias, dependências e responsabilidade financeira não são reescritos.
Repetir o mesmo token devolve o evento já gravado sem duplicar, revalidando contexto/permissões e o estado resultante esperado. Mudanças posteriores exigem nova conferência. Duas prévias distintas sobre o mesmo estado não se substituem: a segunda recebe conflito depois da primeira decisão. O prazo de 15 minutos também se aplica às repetições.
Reutiliza `access.manage`, `<área>.view`, `<área>.edit` nos âmbitos originais e disponibilidade segundo `ModuleNavigation`. Dependências exigem consulta, sem exigir/conceder edição. Descoberta sintética continua sem conceder operações. Sem dupla aprovação, atribuição em lote ou direitos novos.
## Provas
- **17 testes / 221 asserções:** fila e navegação, incluindo filtros, estados, paginação, pesquisa literal, tenant/empresa/unidade, dependências inacessíveis, falta/revogação de direitos, configuração, contexto antigo, tokens inválidos/expirados, versões antigas, ausência de propagação e histórico antes/depois.
- **Concorrência real:** dois subprocessos PHP observados em espera no bloqueio de empresa. Prévias distintas: 200/409. Mesma prévia: 200/200, uma resposta de repetição e um evento.
- **Recuperação:** trigger sintético `AFTER INSERT` provoca falha de auditoria; a transacção desfaz a inserção, a fila continua sem associação e o mesmo token confirma depois de retirada a falha. Nova repetição devolve o evento original.
- **Volume delimitado:** 137 registos correspondentes, páginas de 100/37, sem omissões/duplicações; outro tenant excluído. Não é teste de carga.
- **25 testes / 110 asserções** de identidade, isolamento empresarial e guardas, afectados pela integração administrativa/auditoria.
- Build Vite, controlos de navegação/transições, renderização da fila/impacto com escape de texto e sintaxe PHP aprovados. Sem navegador.
PHP 8.4.27 / Node 24.22.1, base **sgf_tempo_test**, role **sgf_test_runner**, cluster **sgf_modular_regression_2576427d1939**, porta **55439**, cópia `C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/app`. Adaptações físicas só na cópia; guarda original preservada. A prova concorrente confirma fixtures sintéticas exclusivamente nesta base para os processos independentes as consultarem. Não se executa na base persistente de 8039, embora partilhe o cluster isolado.
[Prova consolidada](proofs/module-association-queue-20261102.json) · [preservação dos pendentes](proofs/module-association-queue-preservation-20261102.json). Consulta READ ONLY comparou IDs, versões, referências e associações com o inventário anterior: **os cinco registos locais e o cliente sintético 8039 permanecem iguais e sem associação**. Não houve escrita nesses destinos. Trabalho anterior de Compras preservado.
## Utilização e verificações
No checkout local: **Opções gerais → Associações de módulos**, com os direitos existentes. Frontend compilado; sem instalação/reinício da demonstração 8039. Não usar os pendentes protegidos para experimentar confirmações.
O runner existente aceita `sync` para actualizar apenas a cópia de regressão e `focused --filter "ModuleAssociationReviewTest|ModuleNavigationTest"` para a fila. Usar `C:/laragon/tools/sgf-tempo/node-v24.22.1-win-arm64/node.exe` com o PHP fixado do SGF no PATH **do processo**. Não executar `prepare` sobre o cluster existente, usar ferramentas do PATH global ou executar PHPUnit na demonstração persistente.
Build: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build`. Render dirigido: Node fixado com `scripts/module-association-check.mjs`. O inventário anterior permanece exclusivamente de leitura.
## Limitações
Revisão visual, interacção no navegador e recargas continuam separadas. Histórico apresentado: 20 eventos recentes; auditoria integral preservada. Dependências cobrem referências explícitas das quatro áreas, sem inventariar todos os consumidores inversos, facturas ou outros módulos. A retirada avisa do possível impacto nesses percursos, sem modificar os seus documentos.
A paginação limita a resposta, mas contagens/filtros ainda consultam auditoria JSON e a descrição faz consultas por registo/dependência. 137 registos não validam latência com históricos grandes. Planos, índices e carga de implantação permanecem pendentes; nenhum índice/schema foi criado. Serialização por empresa pode causar contenção administrativa.
Tokens expirados ou mudanças exigem nova conferência, também depois de confirmação anterior. Clientes do antigo POST precisam da prévia obrigatória. Não existe atribuição automática, associação em lote, propagação ou dupla aprovação.
Incidente de ferramenta: `npm exec` descarregou Node 27 alpha para cache, sem alterar manifests/lockfiles. As verificações finais usaram versões fixadas. A revisão automática rejeitou a remoção dessa pasta indicando «blocked by policy», sem motivo específico adicional; a cache foi mantida. Ferramentas fixadas e dependências do projecto não foram actualizadas.
A aceitação local e o checkpoint foram expressamente autorizados posteriormente, conforme o registo acima. Não se presume aceitação visual, avaliação de carga, publicação ou activação operacional.
@@ -0,0 +1,64 @@
{
"date": "2026-11-02",
"scope": "Local administrative module association queue",
"runtime": {
"port": 55439,
"php": "8.4.27",
"node": "24.22.1",
"cluster": "sgf_modular_regression_2576427d1939",
"role": "sgf_test_runner",
"database": "sgf_tempo_test"
},
"focused": {
"assertions": 221,
"errors": 0,
"tests": 17,
"junit_sha256": "69ce9779f96ca8e37387774970d7cd5676d076b173a962b333aa01a44a0492cb",
"failures": 0
},
"foundation": {
"assertions": 110,
"errors": 0,
"tests": 25,
"junit_sha256": "8d4f4022486383066ad6bd33e9ba760b12c5d2ac15ad8a7598167754bea9b0ef",
"failures": 0
},
"concurrency": {
"events_per_decision": 1,
"distinct_reviews_statuses": [
200,
409
],
"independent_php_workers": 2,
"same_review_statuses": [
200,
200
],
"observed_lock_waits": 2
},
"recovery": "Synthetic AFTER INSERT audit trigger raises; transaction rolls back; same preview retries successfully and subsequent replay returns original event",
"synthetic_volume": {
"matching_records": 137,
"page_size": 100,
"page_counts": [
100,
37
],
"production_load_claim": false
},
"build": "passed, Vite 8.3.5; navigation/render/transition controls passed",
"render": "scripts/module-association-check.mjs passed",
"browser_used": false,
"new_runtime_rights": false,
"schema_migrations": false,
"candidate_activation": false,
"publication": false,
"preservation_proof": "module-association-queue-preservation-20261102.json",
"limitations": [
"Recent history limited to 20 events; full append-only audit retained",
"SQL scans and dependent-record queries still require production-volume evaluation",
"Review expires after 15 minutes; retries later require a new review",
"No visual or browser interaction acceptance"
],
"tooling_incident": "npm exec fetched node 27 alpha into an npm cache directory; no manifest or lockfile changes; final validation used pinned Node 24 and PHP 8.4. Removal of the cache directory was rejected by automatic policy review."
}
@@ -0,0 +1,16 @@
{
"date": "2026-11-02",
"read_only": true,
"checks": {
"installed": {
"read_only": "on",
"database": "sgf_tempo_dev",
"pending_records_versions_links_and_references_unchanged": true
},
"demo": {
"read_only": "on",
"database": "sgf_modules_dev_2576427d1939",
"pending_records_versions_links_and_references_unchanged": true
}
}
}
+26 -11
View File
@@ -1,13 +1,28 @@
import React,{useEffect,useState} from 'react'; import React,{useEffect,useRef,useState} from 'react';
const labels={clients:'Clientes',services:'Serviços',service_drafts:'Prestações em rascunho',billing_drafts:'Rascunhos de facturação'}; const labels={clients:'Clientes',services:'Serviços',service_drafts:'Prestações em rascunho',billing_drafts:'Rascunhos de facturação'};
export default function ModuleRecordsWorkspace({context,can,api}) { const moduleName=(id,modules)=>modules.find(m=>m.id===id)?.label||id;
const [data,setData]=useState(null),[error,setError]=useState(''),[busy,setBusy]=useState(false); export function AssociationImpact({review,modules}) {
async function load(){setData(await api('/api/module-records'));} const names=values=>values.length?values.map(id=>moduleName(id,modules)).join(', '):'Sem associação';
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]); return <section className="panel" aria-label="Impacto da decisão"><h2>Conferir antes de confirmar</h2><p>{review.record.name} · #{review.record.id} · versão {review.record.version}</p><p>Antes: {names(review.before)}</p><p>Depois: {names(review.after)}</p><p>Fundamento: {review.reason}</p><p>{review.impact}</p>{review.warnings?.map(w=><p key={w}>{w}</p>)}<h3>Registo e dependências conferidos</h3><ul>{review.dependencies.map(d=><li key={`${d.area}:${d.id}`}>{labels[d.area]} #{d.id} · versão {d.version} · {names(d.modules)}</li>)}</ul><h3>Histórico recente (até 20 eventos)</h3>{review.history.length?<ul>{review.history.map(h=>{const d=typeof h.details==='string'?JSON.parse(h.details):h.details;return <li key={h.id}>{h.created_at} · autor #{h.actor_id} · {h.action} · {d.reason||'Sem fundamento registado no evento antigo'}{d.before_modules&&<> · antes: {names(d.before_modules)} · depois: {names(d.after_modules)}</>}</li>;})}</ul>:<p>Sem eventos anteriores.</p>}</section>;
async function associate(e,area,id,version){ }
e.preventDefault();const form=e.currentTarget,values=Object.fromEntries(new FormData(form));setBusy(true);setError(''); export default function ModuleRecordsWorkspace(props) { return <Queue key={props.context.token} {...props}/>; }
try{await api('/api/module-records','POST',{area,id,version,...values});await load();form.reset();}catch(e){setError(e.message);}finally{setBusy(false);} function Queue({context,can,api}) {
} const areas=Object.keys(labels).filter(a=>can(`${a}.view`,a.endsWith('_drafts')?context.unit?.id??null:null)&&(!a.endsWith('_drafts')||context.unit));
return <section className="panel"><h1>Associações de módulos</h1><p>Configuração geral da empresa, sujeita aos direitos de consulta originais. Esta vista permite localizar os dados existentes que ainda não têm associação. Não é uma consulta operacional do módulo activo.</p><p>A associação e a sua retirada geram eventos novos e auditados; o original permanece. Não transfere responsabilidade financeira, não cria uma cópia e não concede acesso. Para associar são necessários gestão de acessos, consulta e edição do registo, além de acesso ao módulo de destino.</p>{error&&<p role="alert">{error}</p>}{!data?<p>A carregar…</p>:<>{Object.entries(data.records).map(([area,records])=><section key={area}><h2>{labels[area]}</h2>{!records.length&&<p>Sem registos neste âmbito autorizado.</p>}{records.map(r=><article className="panel" key={r.id}><strong>#{r.id} · {r.name} · versão {r.version}</strong><p>{r.modules.length?`Associado a: ${r.modules.join(', ')}`:'Sem associação a módulo — pendente de conferência explícita.'}</p>{can('access.manage')&&can(`${area}.edit`,area.endsWith('_drafts')?context.unit?.id??null:null)&&<form onSubmit={e=>associate(e,area,r.id,r.version)}><label>Operação<select name="operation"><option value="associate">Associar</option><option value="remove">Retirar associação por novo evento</option></select></label><label>Módulo de destino<select name="module_id" required defaultValue=""><option value="">Escolher módulo autorizado</option>{data.modules.map(m=><option key={m.id} value={m.id}>{m.label}</option>)}</select></label><label>Motivo e fundamento da associação<input name="reason" required minLength={3} maxLength={500}/></label><button disabled={busy}>Registar decisão explícita</button></form>}</article>)}</section>)}</>}</section>; const [area,setArea]=useState(areas[0]||'clients'),[state,setState]=useState('pending'),[search,setSearch]=useState(''),[query,setQuery]=useState(''),[page,setPage]=useState(1);
const [data,setData]=useState(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false),[review,setReview]=useState(null),[message,setMessage]=useState(''),[refresh,setRefresh]=useState(0);
const generation=useRef(0);
useEffect(()=>{const current=++generation.current;setLoading(true);setData(null);setReview(null);setError('');
const params=new URLSearchParams({area,state,search:query,page:String(page),per_page:'25'});
api(`/api/module-records?${params}`).then(result=>{if(current===generation.current)setData(result);}).catch(e=>{if(current===generation.current)setError(e.message);}).finally(()=>{if(current===generation.current)setLoading(false);});
return ()=>{generation.current++;};
},[context.token,area,state,query,page,refresh]);
async function preview(e,row){e.preventDefault();setBusy(true);setError('');setMessage('');setReview(null);const current=generation.current;
try{const result=await api('/api/module-records/preview','POST',{area,id:row.id,version:row.version,...Object.fromEntries(new FormData(e.currentTarget))});if(current===generation.current)setReview(result);}catch(e){setError(e.message);}finally{setBusy(false);}}
async function confirm(){setBusy(true);setError('');try{const result=await api('/api/module-records','POST',{review_token:review.review_token});setReview(null);setMessage(result.replayed?'Decisão já registada; repetição reconhecida sem duplicação.':'Decisão individual registada. Dependências preservadas.');setRefresh(n=>n+1);}catch(e){setError(`${e.message} Pode repetir a confirmação se a resposta se perdeu; se o servidor indicar mudança, cancele e faça nova conferência.`);}finally{setBusy(false);}}
function reset(set,value){set(value);setPage(1);setReview(null);}
return <section className="panel"><h1>Conferência de associações de módulos</h1><p>Opções gerais da empresa. Apenas registos do âmbito autorizado; os rascunhos pertencem à unidade activa: {context.unit?.name||'nenhuma seleccionada'}.</p><p>Cada decisão é individual e auditada. Não altera direitos, documentos ou responsabilidade financeira e não associa dependências automaticamente.</p>{error&&<p role="alert">{error}</p>}{message&&<p role="status">{message}</p>}
<fieldset disabled={busy}><legend>Filtros da fila</legend><label>Área<select value={area} onChange={e=>reset(setArea,e.target.value)}>{areas.map(a=><option key={a} value={a}>{labels[a]}</option>)}</select></label><label>Estado<select value={state} onChange={e=>reset(setState,e.target.value)}><option value="pending">Sem eventos de associação</option><option value="associated">Com associação activa</option><option value="removed">Todas as associações retiradas</option><option value="all">Todos</option></select></label><form onSubmit={e=>{e.preventDefault();reset(setQuery,search);}}><label>Nome do registo<input value={search} maxLength={100} onChange={e=>setSearch(e.target.value)}/></label><button>Pesquisar</button></form></fieldset>
{loading&&<p role="status">A carregar…</p>}{data&&<><p>{data.pagination.total} registos · página {data.pagination.page} de {data.pagination.last_page}</p><div className="actions"><button disabled={busy||page<=1} onClick={()=>setPage(p=>p-1)}>Anterior</button><button disabled={busy||page>=data.pagination.last_page} onClick={()=>setPage(p=>p+1)}>Seguinte</button></div>{(data.records[area]||[]).map(row=><article className="panel" key={row.id}><h2>#{row.id} · {row.name} · versão {row.version}</h2><p>Associações: {row.modules.length?row.modules.map(id=>moduleName(id,data.modules)).join(', '):'Sem associação activa — exige decisão explícita.'}</p><h3>Dependências</h3>{row.dependencies_restricted?<p>Conferência bloqueada: dependência fora do âmbito de consulta autorizado.</p>:row.dependencies.length?<ul>{row.dependencies.map(d=><li key={`${d.area}:${d.id}`}>{labels[d.area]} #{d.id} · versão {d.version} · {d.modules.map(id=>moduleName(id,data.modules)).join(', ')||'Sem associação'}</li>)}</ul>:<p>Sem dependências explícitas neste percurso.</p>}
{!row.dependencies_restricted&&can('access.manage')&&can(`${area}.edit`,area.endsWith('_drafts')?context.unit?.id??null:null)&&<form onSubmit={e=>preview(e,row)} onChange={()=>setReview(null)}><fieldset disabled={busy}><legend>Decisão individual</legend><label>Operação<select name="operation"><option value="associate">Associar</option><option value="remove">Retirar associação</option></select></label><label>Módulo de destino<select name="module_id" required defaultValue=""><option value="">Escolher módulo autorizado</option>{data.modules.map(m=><option key={m.id} value={m.id}>{m.label}</option>)}</select></label><label>Fundamento<textarea name="reason" required minLength={3} maxLength={500}/></label><button>Conferir impacto</button></fieldset></form>}</article>)}{!data.records[area]?.length&&<p>Sem registos para estes filtros.</p>}{review&&<><AssociationImpact review={review} modules={data.modules}/><div className="actions"><button disabled={busy} onClick={confirm}>Confirmar esta decisão individual</button><button disabled={busy} onClick={()=>setReview(null)}>Cancelar e voltar à conferência</button></div></>}</>}
</section>;
} }
+1
View File
@@ -28,6 +28,7 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
Route::post('/api/context', [IdentityController::class, 'select']); Route::post('/api/context', [IdentityController::class, 'select']);
Route::middleware(RequireCompanyContext::class)->group(function (): void { Route::middleware(RequireCompanyContext::class)->group(function (): void {
Route::get('/api/module-records', [\App\Modules\Identity\Http\ModuleRecordsController::class,'index']); Route::get('/api/module-records', [\App\Modules\Identity\Http\ModuleRecordsController::class,'index']);
Route::post('/api/module-records/preview', [\App\Modules\Identity\Http\ModuleRecordsController::class,'preview']);
Route::post('/api/module-records', [\App\Modules\Identity\Http\ModuleRecordsController::class,'associate']); Route::post('/api/module-records', [\App\Modules\Identity\Http\ModuleRecordsController::class,'associate']);
Route::get('/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'page']); Route::get('/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'page']);
Route::get('/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'page']); Route::get('/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'page']);
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
const server=await createServer({server:{middlewareMode:true},appType:'custom',optimizeDeps:{noDiscovery:true,include:[]}});
try {
const {default:Queue,AssociationImpact}=await server.ssrLoadModule('/resources/js/ModuleRecordsWorkspace.jsx');
const modules=[{id:'commercial',label:'Comercial'},{id:'workshop',label:'Oficinas'}];
const review={record:{id:7,name:'<script>synthetic</script>',version:3},before:['commercial'],after:['commercial','workshop'],reason:'Explicit <review>',impact:'Only this record',warnings:['Dependencies remain pending'],
dependencies:[{area:'services',id:8,version:2,modules:[]}],history:[{id:1,actor_id:9,created_at:'2026-11-02',action:'module.record.associated',details:JSON.stringify({reason:'Earlier review',before_modules:[],after_modules:['commercial']})}]};
const html=renderToStaticMarkup(React.createElement(AssociationImpact,{review,modules}));
for(const expected of ['Antes: Comercial','Depois: Comercial, Oficinas','versão 3','Explicit &lt;review&gt;','Dependencies remain pending','autor #9','Earlier review','Serviços #8'])assert(html.includes(expected),expected);
assert(!html.includes('<script>'));
const initial=renderToStaticMarkup(React.createElement(Queue,{context:{token:'synthetic',unit:{id:4,name:'Synthetic unit'}},can:p=>['access.manage','clients.view'].includes(p),api:()=>{throw Error('SSR must not execute requests');}}));
assert(initial.includes('Filtros da fila'));assert(initial.includes('Synthetic unit'));assert(initial.includes('Sem eventos de associação'));
assert(!initial.includes('value="service_drafts"'));assert(!initial.includes('Confirmar esta decisão individual'));
console.log('Association render checks passed: impact, before/after, dependencies, history, escaping, authorized area filters and no confirmation before preview. No browser.');
} finally {await server.close();}
@@ -0,0 +1,170 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\Group('modular-focused')]
final class ModuleAssociationReviewTest extends TestCase
{
use DatabaseTransactions;
private array $f;
protected function setUp(): void {
parent::setUp(); $this->f=IdentityFixture::create('-review-'.bin2hex(random_bytes(5)));
foreach(['clients','services','service_drafts','billing_drafts'] as $area) foreach(['view','create','edit'] as $action)
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],"$area.$action",str_ends_with($area,'_drafts')?$this->f['unit']:null);
$actor=$this->f['manager']; $this->actingAs($actor)->withSession(['auth_version'=>$actor->auth_version]); $this->select();
}
private function select(): string {
$token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>null])->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token); return $token;
}
private function record(string $area='clients',array $extra=[]): int {
return DB::table('a_'.$area)->insertGetId($extra+['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>str_ends_with($area,'_drafts')?$this->f['unit']:null,'name'=>'Synthetic review','version'=>1,'data'=>'{}','created_at'=>now(),'updated_at'=>now()]);
}
private function preview(int $id,string $area='clients',array $extra=[]) {
return $this->postJson('/api/module-records/preview',$extra+['area'=>$area,'id'=>$id,'version'=>1,'module_id'=>'commercial','operation'=>'associate','reason'=>'Explicit synthetic review']);
}
private function confirm(string $token) {return $this->postJson('/api/module-records',['review_token'=>$token]);}
private function draft(): array {
$client=$this->record();$service=$this->record('services');
$data=json_encode(['currency'=>'MZN','tax_status'=>'pending','accounting_status'=>'not_processed','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>'10']]]);
$draft=$this->record('service_drafts',['client_id'=>$client,'data'=>$data]);
return [$draft,$client,$service];
}
public function test_server_filters_pagination_and_originals_are_preserved(): void {
$ids=[];for($i=0;$i<7;$i++)$ids[]=$this->record('clients',['name'=>'Queue '.$i]);
$before=DB::table('a_clients')->whereIn('id',$ids)->orderBy('id')->get()->toJson();
$this->getJson('/api/module-records?area=clients&state=pending&per_page=3&page=2&search=Queue')->assertOk()->assertJsonPath('pagination.total',7)->assertJsonPath('records.clients.0.id',$ids[3])->assertJsonCount(3,'records.clients');
$this->confirm($this->preview($ids[0])->assertOk()->json('review_token'))->assertOk();
$this->getJson('/api/module-records?state=associated')->assertOk()->assertJsonPath('pagination.total',1);
$this->getJson('/api/module-records?state=pending')->assertOk()->assertJsonPath('pagination.total',6);
$this->confirm($this->preview($ids[0],'clients',['operation'=>'remove'])->assertOk()->json('review_token'))->assertOk();
$this->getJson('/api/module-records?state=removed')->assertOk()->assertJsonPath('records.clients.0.id',$ids[0]);
$this->getJson('/api/module-records?per_page=101')->assertUnprocessable();
$this->getJson('/api/module-records?page=0')->assertUnprocessable();
$this->assertSame($before,DB::table('a_clients')->whereIn('id',$ids)->orderBy('id')->get()->toJson());
}
public function test_preview_is_mandatory_and_individual_history_and_retry_are_exact(): void {
$id=$this->record();$other=$this->record();$preview=$this->preview($id)->assertOk()->assertJsonPath('before',[])->assertJsonPath('after',['commercial']);
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'commercial','reason'=>'Bypass'])->assertUnprocessable();
$token=$preview->json('review_token');$event=$this->confirm($token)->assertOk()->assertJsonPath('replayed',false)->json('event_id');
$this->confirm($token)->assertOk()->assertJsonPath('replayed',true)->assertJsonPath('event_id',$event);
$row=DB::table('identity_audit')->where('id',$event)->first();$d=json_decode($row->details,true);
$this->assertSame($this->f['manager']->id,$row->actor_id);$this->assertNotNull($row->created_at);
$this->assertSame([],$d['before_modules']);$this->assertSame(['commercial'],$d['after_modules']);$this->assertSame('Explicit synthetic review',$d['reason']);
$this->assertSame(1,DB::table('identity_audit')->whereRaw("details::jsonb->>'decision_id' = ?",[$d['decision_id']])->count());
$this->assertSame(0,DB::table('identity_audit')->where('action','module.record.associated')->whereRaw("details::jsonb->>'record_id' = ?",[(string)$other])->count());
$this->preview($id,'clients',['reason'=>' '])->assertUnprocessable();
$this->preview($id,'clients',['module_id'=>'invented'])->assertForbidden();
$this->confirm($token.'tampered')->assertUnprocessable();
$this->confirm(\Illuminate\Support\Facades\Crypt::encryptString('{}'))->assertUnprocessable();
}
public function test_stale_record_competing_reviews_and_context_are_rejected(): void {
$id=$this->record();$a=$this->preview($id)->assertOk()->json('review_token');$b=$this->preview($id)->assertOk()->json('review_token');
$this->confirm($a)->assertOk();$this->confirm($b)->assertConflict();
$fresh=$this->preview($id)->assertOk()->json('review_token');
DB::table('a_clients')->where('id',$id)->update(['name'=>'Changed','version'=>2]);
$this->confirm($fresh)->assertConflict();$this->preview($id)->assertConflict();$this->confirm($a)->assertConflict();
$id=$this->record();$token=$this->preview($id)->assertOk()->json('review_token');$this->select();$this->confirm($token)->assertConflict();
}
public function test_dependencies_are_rechecked_and_never_propagated(): void {
[$draft,$client,$service]=$this->draft();
$token=$this->preview($draft,'service_drafts')->assertOk()->assertJsonCount(3,'dependencies')->json('review_token');
DB::table('a_services')->where('id',$service)->update(['version'=>2,'name'=>'Changed dependency']);
$this->confirm($token)->assertConflict();
$token=$this->preview($draft,'service_drafts')->assertOk()->json('review_token');$this->confirm($token)->assertOk();
$this->getJson('/api/module-records?area=clients&state=pending')->assertOk()->assertJsonPath('records.clients.0.id',$client);
$this->getJson('/api/module-records?area=services&state=pending')->assertOk()->assertJsonPath('records.services.0.id',$service);
$preview=$this->preview($draft,'service_drafts')->assertOk()->json('review_token');
$this->confirm($this->preview($client)->assertOk()->json('review_token'))->assertOk();
$this->confirm($preview)->assertConflict();
}
public function test_company_unit_permissions_and_dependency_visibility(): void {
$foreign=$this->record('clients',['company_id'=>$this->f['sibling']]);$id=$this->record();
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('pagination.total',1)->assertJsonPath('records.clients.0.id',$id);
$this->preview($foreign)->assertForbidden();$this->getJson('/api/module-records?unit_id='.$this->f['hidden'])->assertForbidden();
[$draft]=$this->draft();
$hidden=$this->record('service_drafts',['client_id'=>DB::table('a_service_drafts')->where('id',$draft)->value('client_id'),'data'=>DB::table('a_service_drafts')->where('id',$draft)->value('data'),'unit_id'=>$this->f['hidden']]);
$this->getJson('/api/module-records?area=service_drafts')->assertOk()->assertJsonPath('pagination.total',1);
$this->preview($hidden,'service_drafts')->assertForbidden();
$token=$this->preview($id)->assertOk()->json('review_token');
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('company_id',$this->f['company'])->where('permission','clients.edit')->delete();
$this->confirm($token)->assertForbidden();
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('company_id',$this->f['company'])->where('permission','services.view')->delete();
$this->getJson('/api/module-records?area=service_drafts')->assertOk()->assertJsonPath('records.service_drafts.0.dependencies_restricted',true)->assertJsonPath('records.service_drafts.0.dependencies',[]);
$this->preview($draft,'service_drafts')->assertForbidden();
$reader=$this->f['reader'];$this->actingAs($reader)->withSession(['auth_version'=>$reader->auth_version]);$this->select();
$this->getJson('/api/module-records')->assertForbidden();$this->preview($id)->assertForbidden();
}
public function test_audit_failure_rolls_back_and_same_review_can_recover(): void {
$id=$this->record();$token=$this->preview($id)->assertOk()->json('review_token');
DB::unprepared("CREATE FUNCTION pg_temp.fail_module_review() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='module.record.associated' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER synthetic_review_failure AFTER INSERT ON identity_audit FOR EACH ROW EXECUTE FUNCTION pg_temp.fail_module_review()");
$this->confirm($token)->assertStatus(500);
$this->getJson('/api/module-records?state=associated')->assertOk()->assertJsonPath('pagination.total',0);
DB::unprepared('DROP TRIGGER synthetic_review_failure ON identity_audit');
$this->confirm($token)->assertOk()->assertJsonPath('replayed',false);$this->confirm($token)->assertOk()->assertJsonPath('replayed',true);
}
public function test_expiry_configuration_scope_injection_and_stale_read_context(): void {
$id=$this->record();$token=$this->preview($id)->assertOk()->json('review_token');
$p=json_decode(\Illuminate\Support\Facades\Crypt::decryptString($token),true);$p['expires']=time()-1;
$this->confirm(\Illuminate\Support\Facades\Crypt::encryptString(json_encode($p)))->assertConflict();
$this->getJson('/api/module-records?company_id='.$this->f['sibling'])->assertUnprocessable();
config(['modules.contracted'=>[]]);$this->confirm($token)->assertForbidden();
$this->withHeader('X-SGF-Context','obsolete')->getJson('/api/module-records')->assertConflict();
}
public function test_bounded_synthetic_volume_and_literal_search(): void {
$ids=[];for($i=0;$i<137;$i++)$ids[]=$this->record('clients',['name'=>'Volume '.$i]);
$this->record('clients',['tenant_id'=>$this->f['otherTenant'],'company_id'=>$this->f['foreign'],'name'=>'Foreign volume']);
$first=$this->getJson('/api/module-records?per_page=100&search=Volume')->assertOk()->assertJsonPath('pagination.total',137)->assertJsonCount(100,'records.clients')->json('records.clients');
$second=$this->getJson('/api/module-records?per_page=100&page=2&search=Volume')->assertOk()->assertJsonPath('pagination.total',137)->assertJsonCount(37,'records.clients')->json('records.clients');
$this->assertSame($ids,array_column([...$first,...$second],'id'));
$this->getJson('/api/module-records?search=%25')->assertOk()->assertJsonPath('pagination.total',0);
$this->record('clients',['name'=>'Literal % marker']);
$this->getJson('/api/module-records?search=%25')->assertOk()->assertJsonPath('pagination.total',1);
}
public function test_real_concurrent_confirmations_serialize_and_duplicate_retry_has_one_event(): void {
// Only this disposable regression DB is committed so separate PHP workers
// can see the synthetic fixture. No persistent demo is addressed.
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D',(string)getenv('SGF_ISOLATED_REGRESSION_RUN'));
$context=$this->select();
foreach ([false,true] as $sameReview) {
$id=$this->record();
$a=$this->preview($id)->assertOk()->json('review_token');
$b=$sameReview?$a:$this->preview($id)->assertOk()->json('review_token');
DB::commit(); DB::beginTransaction();
DB::table('companies')->where('id',$this->f['company'])->lockForUpdate()->firstOrFail();
$jobs=[];
try {
foreach ([$a,$b] as $token) {
$file=tempnam(sys_get_temp_dir(),'sgf-assoc-');
file_put_contents($file,json_encode(['actor'=>$this->f['manager']->id,'tenant'=>$this->f['tenant'],'company'=>$this->f['company'],'unit'=>$this->f['unit'],'context'=>$context,'review_token'=>$token]));
$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/association-confirm-worker.php'),$file],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes,base_path());
$this->assertIsResource($process); fclose($pipes[0]);$jobs[]=[$process,$pipes,$file];
}
$waiting=0;$deadline=microtime(true)+15;
do {
DB::select('SELECT pg_stat_clear_snapshot()');
$waiting=(int)DB::selectOne("SELECT count(*) AS n FROM pg_stat_activity WHERE datname=current_database() AND application_name='sgf_association_worker' AND wait_event_type='Lock'")->n;
if ($waiting>=2) break; usleep(50000);
} while (microtime(true)<$deadline);
DB::commit();DB::beginTransaction();
$results=[];
foreach ($jobs as [$process,$pipes,$file]) {
$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
$this->assertSame(0,proc_close($process),$err.$out);$results[]=json_decode($out,true,512,JSON_THROW_ON_ERROR);
}
$this->assertSame(2,$waiting,'Both independent workers must contend on the company lock: '.json_encode($results));
$statuses=array_column($results,'status');sort($statuses);
$this->assertSame($sameReview?[200,200]:[200,409],$statuses);
if($sameReview)$this->assertCount(1,array_filter($results,fn($r)=>$r['replayed']));
$this->assertSame(1,DB::table('identity_audit')->where('tenant_id',$this->f['tenant'])->where('company_id',$this->f['company'])->where('action','module.record.associated')->whereRaw("details::jsonb->>'record_id' = ?",[(string)$id])->count());
} finally {
foreach ($jobs as [$process,$pipes,$file]) {
if(is_resource($process)){proc_terminate($process);foreach($pipes as $pipe)if(is_resource($pipe))fclose($pipe);proc_close($process);}
if(is_file($file))unlink($file);if(is_file($file.'.ready'))unlink($file.'.ready');
}
}
}
}
}
+13 -6
View File
@@ -25,6 +25,13 @@ final class ModuleNavigationTest extends TestCase
{ {
return $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$company??$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>$module]); return $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$company??$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>$module]);
} }
private function decide(array $input)
{
$input['operation'] ??= 'associate';
$preview = $this->postJson('/api/module-records/preview', $input);
if ($preview->status() !== 200) return $preview;
return $this->postJson('/api/module-records', ['review_token' => $preview->json('review_token')]);
}
public function test_explicit_selection_direct_access_and_old_generation(): void public function test_explicit_selection_direct_access_and_old_generation(): void
{ {
$this->select(null)->assertOk(); $this->select(null)->assertOk();
@@ -95,15 +102,15 @@ final class ModuleNavigationTest extends TestCase
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]); $this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]);
$this->getJson('/api/a/clients/'.$id)->assertForbidden(); $this->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id); $this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id);
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Explicit synthetic association'])->assertOk(); $this->decide(['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Explicit synthetic association'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertOk()->assertJsonPath('name','Module proof client'); $this->getJson('/api/a/clients/'.$id)->assertOk()->assertJsonPath('name','Module proof client');
$this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version')); $this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version'));
$this->assertSame(2,DB::table('identity_audit')->where('action','module.record.associated')->where('company_id',$this->f['company'])->count()); $this->assertSame(2,DB::table('identity_audit')->where('action','module.record.associated')->where('company_id',$this->f['company'])->count());
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Correct association without rewriting original','operation'=>'remove'])->assertOk(); $this->decide(['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Correct association without rewriting original','operation'=>'remove'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertForbidden(); $this->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.modules',['commercial']); $this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.modules',['commercial']);
$this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version')); $this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version'));
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Re-association explicitly reviewed'])->assertOk(); $this->decide(['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Re-association explicitly reviewed'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertOk(); $this->getJson('/api/a/clients/'.$id)->assertOk();
IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['sibling'],'clients.view'); IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['sibling'],'clients.view');
$token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit'],'module_id'=>'commercial'])->assertOk()->json('context.token'); $token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit'],'module_id'=>'commercial'])->assertOk()->json('context.token');
@@ -120,7 +127,7 @@ final class ModuleNavigationTest extends TestCase
$this->getJson('/api/module-records')->assertForbidden(); $this->getJson('/api/module-records')->assertForbidden();
IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],'access.manage'); IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],'access.manage');
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id)->assertJsonPath('records.clients.0.modules',[]); $this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id)->assertJsonPath('records.clients.0.modules',[]);
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'commercial','reason'=>'Cannot assign without edit permission'])->assertForbidden(); $this->decide(['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'commercial','reason'=>'Cannot assign without edit permission'])->assertForbidden();
$this->assertSame('Existing unassociated record',DB::table('a_clients')->where('id',$id)->value('name')); $this->assertSame('Existing unassociated record',DB::table('a_clients')->where('id',$id)->value('name'));
} }
@@ -141,8 +148,8 @@ final class ModuleNavigationTest extends TestCase
$original=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json(); $original=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json();
$this->assertSame($draft,$original['source_id']); $this->assertSame($draft,$original['source_id']);
$token=$this->select('workshop')->assertOk()->json('context.token'); $token=$this->select('workshop')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->postJson('/api/module-records',['area'=>'billing_drafts','id'=>$bill,'version'=>99,'module_id'=>'workshop','reason'=>'Reject stale review'])->assertStatus(409); $this->withHeader('X-SGF-Context',$token)->decide(['area'=>'billing_drafts','id'=>$bill,'version'=>99,'module_id'=>'workshop','reason'=>'Reject stale review'])->assertStatus(409);
$this->postJson('/api/module-records',['area'=>'billing_drafts','id'=>$bill,'version'=>1,'module_id'=>'workshop','reason'=>'Explicitly share this preserved draft'])->assertOk(); $this->decide(['area'=>'billing_drafts','id'=>$bill,'version'=>1,'module_id'=>'workshop','reason'=>'Explicitly share this preserved draft'])->assertOk();
$record=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json(); $record=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json();
$this->assertSame($original['data'],$record['data']); $this->assertSame($original['data'],$record['data']);
$this->assertSame($original['history'],$record['history']); $this->assertSame($original['history'],$record['history']);
@@ -0,0 +1,23 @@
<?php
// Test-only subprocess, never a route. Its private input contains no credentials.
putenv('APP_ENV=testing');
require dirname(__DIR__, 2).'/vendor/autoload.php';
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$p = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
$db = Illuminate\Support\Facades\DB::connection();
$cluster = $db->selectOne("SELECT current_setting('cluster_name') AS name")->name;
if ($cluster !== 'sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN')) throw new RuntimeException('Wrong test cluster');
$db->statement("SET application_name = 'sgf_association_worker'");
$context = app(App\Shared\Context\CompanyContext::class);
$context->selectFor($p['actor'], $p['tenant'], $p['company'], $p['unit']);
$r = Illuminate\Http\Request::create('/api/module-records', 'POST', ['review_token' => $p['review_token']]);
$r->headers->set('X-SGF-Context', $p['context']);
file_put_contents($argv[1].'.ready', 'ready');
try {
$result = app(App\Modules\Identity\Services\ModuleAssociationReview::class)->confirm($r);
echo json_encode(['status' => 200, ...$result], JSON_THROW_ON_ERROR);
} catch (Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e) {
echo json_encode(['status' => $e->getStatusCode()], JSON_THROW_ON_ERROR);
}