From 340f3bb5fa0e72d659abe43f3498b49e25ca3809 Mon Sep 17 00:00:00 2001 From: nala Date: Mon, 2 Nov 2026 11:20:13 +0200 Subject: [PATCH] Aceita fila administrativa de associacoes com checkpoint local --- README.md | 2 + .../Identity/Http/ModuleRecordsController.php | 53 +---- app/Modules/Identity/Services/Audit.php | 2 +- .../Services/ModuleAssociationReview.php | 211 ++++++++++++++++++ docs/MODULAR_NAVIGATION_COMPLETION.md | 4 + docs/MODULE_ASSOCIATION_QUEUE_ACCEPTANCE.md | 15 ++ docs/MODULE_ASSOCIATION_QUEUE_DELIVERY.md | 66 ++++++ .../module-association-queue-20261102.json | 64 ++++++ ...sociation-queue-preservation-20261102.json | 16 ++ resources/js/ModuleRecordsWorkspace.jsx | 37 ++- routes/web.php | 1 + scripts/module-association-check.mjs | 19 ++ tests/Feature/ModuleAssociationReviewTest.php | 170 ++++++++++++++ tests/Feature/ModuleNavigationTest.php | 19 +- tests/Support/association-confirm-worker.php | 23 ++ 15 files changed, 635 insertions(+), 67 deletions(-) create mode 100644 app/Modules/Identity/Services/ModuleAssociationReview.php create mode 100644 docs/MODULE_ASSOCIATION_QUEUE_ACCEPTANCE.md create mode 100644 docs/MODULE_ASSOCIATION_QUEUE_DELIVERY.md create mode 100644 docs/proofs/module-association-queue-20261102.json create mode 100644 docs/proofs/module-association-queue-preservation-20261102.json create mode 100644 scripts/module-association-check.mjs create mode 100644 tests/Feature/ModuleAssociationReviewTest.php create mode 100644 tests/Support/association-confirm-worker.php diff --git a/README.md b/README.md index 0a08b4b..68d3cad 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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. **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. diff --git a/app/Modules/Identity/Http/ModuleRecordsController.php b/app/Modules/Identity/Http/ModuleRecordsController.php index dd354da..cff68a2 100644 --- a/app/Modules/Identity/Http/ModuleRecordsController.php +++ b/app/Modules/Identity/Http/ModuleRecordsController.php @@ -1,55 +1,10 @@ identifiers();$out=[]; - $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]); - } + 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'); } + public function associate(Request $request, ModuleAssociationReview $review) { return response()->json($review->confirm($request))->header('Cache-Control', 'no-store'); } } diff --git a/app/Modules/Identity/Services/Audit.php b/app/Modules/Identity/Services/Audit.php index d732512..08b5f97 100644 --- a/app/Modules/Identity/Services/Audit.php +++ b/app/Modules/Identity/Services/Audit.php @@ -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 { // 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([ 'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject, diff --git a/app/Modules/Identity/Services/ModuleAssociationReview.php b/app/Modules/Identity/Services/ModuleAssociationReview.php new file mode 100644 index 0000000..fab23e1 --- /dev/null +++ b/app/Modules/Identity/Services/ModuleAssociationReview.php @@ -0,0 +1,211 @@ +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); + } +} diff --git a/docs/MODULAR_NAVIGATION_COMPLETION.md b/docs/MODULAR_NAVIGATION_COMPLETION.md index 3f88ece..4ab24c4 100644 --- a/docs/MODULAR_NAVIGATION_COMPLETION.md +++ b/docs/MODULAR_NAVIGATION_COMPLETION.md @@ -1,5 +1,9 @@ # 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**. 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. diff --git a/docs/MODULE_ASSOCIATION_QUEUE_ACCEPTANCE.md b/docs/MODULE_ASSOCIATION_QUEUE_ACCEPTANCE.md new file mode 100644 index 0000000..271a22f --- /dev/null +++ b/docs/MODULE_ASSOCIATION_QUEUE_ACCEPTANCE.md @@ -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. diff --git a/docs/MODULE_ASSOCIATION_QUEUE_DELIVERY.md b/docs/MODULE_ASSOCIATION_QUEUE_DELIVERY.md new file mode 100644 index 0000000..f528ffb --- /dev/null +++ b/docs/MODULE_ASSOCIATION_QUEUE_DELIVERY.md @@ -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. diff --git a/docs/proofs/module-association-queue-20261102.json b/docs/proofs/module-association-queue-20261102.json new file mode 100644 index 0000000..8b673ea --- /dev/null +++ b/docs/proofs/module-association-queue-20261102.json @@ -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." +} diff --git a/docs/proofs/module-association-queue-preservation-20261102.json b/docs/proofs/module-association-queue-preservation-20261102.json new file mode 100644 index 0000000..386359a --- /dev/null +++ b/docs/proofs/module-association-queue-preservation-20261102.json @@ -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 + } + } +} diff --git a/resources/js/ModuleRecordsWorkspace.jsx b/resources/js/ModuleRecordsWorkspace.jsx index cfe1a80..c16e7d6 100644 --- a/resources/js/ModuleRecordsWorkspace.jsx +++ b/resources/js/ModuleRecordsWorkspace.jsx @@ -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'}; -export default function ModuleRecordsWorkspace({context,can,api}) { - const [data,setData]=useState(null),[error,setError]=useState(''),[busy,setBusy]=useState(false); - async function load(){setData(await api('/api/module-records'));} - useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]); - async function associate(e,area,id,version){ - e.preventDefault();const form=e.currentTarget,values=Object.fromEntries(new FormData(form));setBusy(true);setError(''); - try{await api('/api/module-records','POST',{area,id,version,...values});await load();form.reset();}catch(e){setError(e.message);}finally{setBusy(false);} - } - return

Associações de módulos

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.

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.

{error&&

{error}

}{!data?

A carregar…

:<>{Object.entries(data.records).map(([area,records])=>

{labels[area]}

{!records.length&&

Sem registos neste âmbito autorizado.

}{records.map(r=>
#{r.id} · {r.name} · versão {r.version}

{r.modules.length?`Associado a: ${r.modules.join(', ')}`:'Sem associação a módulo — pendente de conferência explícita.'}

{can('access.manage')&&can(`${area}.edit`,area.endsWith('_drafts')?context.unit?.id??null:null)&&
associate(e,area,r.id,r.version)}>
}
)}
)}}
; +const moduleName=(id,modules)=>modules.find(m=>m.id===id)?.label||id; +export function AssociationImpact({review,modules}) { + const names=values=>values.length?values.map(id=>moduleName(id,modules)).join(', '):'Sem associação'; + return

Conferir antes de confirmar

{review.record.name} · #{review.record.id} · versão {review.record.version}

Antes: {names(review.before)}

Depois: {names(review.after)}

Fundamento: {review.reason}

{review.impact}

{review.warnings?.map(w=>

{w}

)}

Registo e dependências conferidos

Histórico recente (até 20 eventos)

{review.history.length?:

Sem eventos anteriores.

}
; +} +export default function ModuleRecordsWorkspace(props) { return ; } +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)); + 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

Conferência de associações de módulos

Opções gerais da empresa. Apenas registos do âmbito autorizado; os rascunhos pertencem à unidade activa: {context.unit?.name||'nenhuma seleccionada'}.

Cada decisão é individual e auditada. Não altera direitos, documentos ou responsabilidade financeira e não associa dependências automaticamente.

{error&&

{error}

}{message&&

{message}

} +
Filtros da fila
{e.preventDefault();reset(setQuery,search);}}>
+ {loading&&

A carregar…

}{data&&<>

{data.pagination.total} registos · página {data.pagination.page} de {data.pagination.last_page}

{(data.records[area]||[]).map(row=>

#{row.id} · {row.name} · versão {row.version}

Associações: {row.modules.length?row.modules.map(id=>moduleName(id,data.modules)).join(', '):'Sem associação activa — exige decisão explícita.'}

Dependências

{row.dependencies_restricted?

Conferência bloqueada: dependência fora do âmbito de consulta autorizado.

:row.dependencies.length?
    {row.dependencies.map(d=>
  • {labels[d.area]} #{d.id} · versão {d.version} · {d.modules.map(id=>moduleName(id,data.modules)).join(', ')||'Sem associação'}
  • )}
:

Sem dependências explícitas neste percurso.

} + {!row.dependencies_restricted&&can('access.manage')&&can(`${area}.edit`,area.endsWith('_drafts')?context.unit?.id??null:null)&&
preview(e,row)} onChange={()=>setReview(null)}>
Decisão individual