diff --git a/app/Http/Controllers/EducationController.php b/app/Http/Controllers/EducationController.php index dd7a40a0..e9fe2ade 100644 --- a/app/Http/Controllers/EducationController.php +++ b/app/Http/Controllers/EducationController.php @@ -21,11 +21,13 @@ use App\Services\EducationWorkQueue; use App\Services\InvestmentGeography; use App\Services\PortalAudit; use Carbon\CarbonImmutable; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; @@ -166,6 +168,7 @@ class EducationController extends Controller Inertia::encryptHistory(); return tap(Inertia::render('education/declaration', ['declaration' => $row, 'kind' => $kind, 'actorId' => $request->user()->id, 'today' => now()->toDateString(), + 'preparation' => app(EducationReviews::class)->preparation($request->user(), $record), 'history' => app(EducationReviews::class)->history($request->user(), $record), 'backUrl' => route('management.education.index', $filters, false), 'references' => $references])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } @@ -203,6 +206,29 @@ class EducationController extends Controller return back()->with('success', 'Declaração registada para revisão independente.'); } + public function reviewPreparation(Request $request, string $kind, string $id): JsonResponse + { + $service = app(EducationReviews::class); + app(EducationAccess::class)->authorize($request->user()); + if ($request->isMethod('POST')) { + // Like CRM preparations, retain partial text before business validation. + $input = json_decode($request->getContent(), true); + if (! is_array($input)) { + throw ValidationException::withMessages(['snapshot' => 'A preparação deve ser enviada em JSON.']); + } + $view = $service->savePreparation($request->user(), $kind, $id, $input); + } else { + $expected = $request->validate(['actor_id' => 'sometimes|required|integer|min:1']); + abort_if(isset($expected['actor_id']) && (int) $expected['actor_id'] !== $request->user()->id, 409, 'A conta mudou. Actualize a entrada antes de recuperar.'); + $record = match ($kind) { + 'demands' => LabourDemand::findOrFail($id), 'capacities' => TrainingCapacity::findOrFail($id), default => abort(404) + }; + $view = $service->preparation($request->user(), $record); + } + + return response()->json($view)->header('Cache-Control', 'private, no-store'); + } + public function review(Request $request, string $kind, string $id): RedirectResponse { $result = app(EducationReviews::class)->decide($request->user(), $kind, $id, $request->all()); diff --git a/app/Services/EducationReviews.php b/app/Services/EducationReviews.php index b957eb73..a29cfd89 100644 --- a/app/Services/EducationReviews.php +++ b/app/Services/EducationReviews.php @@ -6,10 +6,13 @@ use App\Models\LabourDemand; use App\Models\TrainingCapacity; use App\Models\User; use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; +use JsonException; final class EducationReviews { @@ -55,6 +58,134 @@ final class EducationReviews }); } + /** Private preparation only; never a decision or an authority grant. + * @return array */ + public function preparation(User $actor, LabourDemand|TrainingCapacity $record): array + { + app(EducationAccess::class)->authorize($actor); + $event = $this->preparations($actor, $record)->orderByRaw("CASE WHEN (after_data->'education_preparation'->>'version') ~ '^[0-9]{1,10}$' THEN (after_data->'education_preparation'->>'version')::bigint END DESC NULLS LAST")->orderByDesc('occurred_at')->orderByDesc('id')->first(['after_data']); + $value = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['education_preparation'] : null; + $snapshot = null; + $unavailable = false; + if ($value) { + try { + $decoded = json_decode(Crypt::decryptString($value['snapshot']), true, 512, JSON_THROW_ON_ERROR); + $snapshot = Validator::make(['snapshot' => $decoded], $this->preparationRules($decoded))->validate()['snapshot']; + } catch (DecryptException|JsonException|ValidationException) { + $unavailable = true; + } + } + // Empty reference maps remain JSON objects in the browser, like the declaration props. + if (is_array($snapshot)) { + if ($snapshot['base']['references'] === []) { + $snapshot['base']['references'] = (object) []; + } + if (is_array($snapshot['pending']) && $snapshot['pending']['base']['references'] === []) { + $snapshot['pending']['base']['references'] = (object) []; + } + } + + return ['actor_id' => $actor->id, 'kind' => $record instanceof LabourDemand ? 'demands' : 'capacities', 'record_id' => $record->id, 'version' => (int) ($value['version'] ?? 0), 'operation_key' => $value['operation_key'] ?? null, + 'snapshot' => $snapshot, 'unavailable' => $unavailable]; + } + + /** @param array $input + * @return array */ + public function savePreparation(User $actor, string $kind, string $id, array $input): array + { + app(EducationAccess::class)->authorize($actor); + $model = match ($kind) { + 'demands' => LabourDemand::class, 'capacities' => TrainingCapacity::class, default => abort(404) + }; + $data = Validator::make($input, [...$this->preparationRules($input['snapshot'] ?? null), + 'actor_id' => 'required|integer|min:1', 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid'])->validate(); + abort_unless((int) $data['actor_id'] === $actor->id, 409, 'A conta mudou. Actualize a entrada antes de guardar a preparação.'); + $key = strtolower($data['operation_key']); + $snapshot = $data['snapshot']; + foreach (['values', 'base'] as $field) { + $snapshot[$field]['version_no'] = (int) $snapshot[$field]['version_no']; + } + $snapshot['base']['quantity'] = (int) $snapshot['base']['quantity']; + foreach (['status', 'review_reason'] as $field) { + $snapshot['generation'][$field] = (int) $snapshot['generation'][$field]; + } + if ($snapshot['pending'] !== null) { + $snapshot['pending']['values']['version_no'] = (int) $snapshot['pending']['values']['version_no']; + $snapshot['pending']['base']['version_no'] = (int) $snapshot['pending']['base']['version_no']; + $snapshot['pending']['base']['quantity'] = (int) $snapshot['pending']['base']['quantity']; + $snapshot['pending']['values']['client_key'] = strtolower($snapshot['pending']['values']['client_key']); + foreach (['reasonGeneration', 'statusGeneration'] as $field) { + $snapshot['pending'][$field] = (int) $snapshot['pending'][$field]; + } + abort_unless($snapshot['pending']['values']['version_no'] === $snapshot['pending']['base']['version_no'], 422); + abort_unless($snapshot['pending']['reasonGeneration'] <= $snapshot['generation']['review_reason'] && $snapshot['pending']['statusGeneration'] <= $snapshot['generation']['status'], 422); + } + abort_unless($snapshot['values']['version_no'] === $snapshot['base']['version_no'], 422); + $hash = hash('sha256', json_encode(Arr::sortRecursive(['actor' => $actor->id, 'kind' => $kind, 'id' => $id, + 'version' => (int) $data['version'], 'snapshot' => $snapshot]), JSON_THROW_ON_ERROR)); + + return DB::transaction(function () use ($actor, $model, $id, $data, $snapshot, $key, $hash): array { + $record = $model::lockForUpdate()->findOrFail($id); + $previous = $this->preparations($actor, $record)->whereRaw("after_data->'education_preparation'->>'operation_key' = ?", [$key])->first(['after_data']); + if ($previous) { + $receipt = json_decode($previous->after_data, true, 512, JSON_THROW_ON_ERROR)['education_preparation']; + abort_unless(hash_equals($receipt['hash'], $hash), 409, 'Esta tentativa de guardar já foi usada com outros dados.'); + + return $this->preparation($actor, $record); + } + $current = $this->preparation($actor, $record); + abort_unless($current['version'] === (int) $data['version'], 409, 'A preparação mudou noutra janela. Compare antes de substituir.'); + abort_if($current['unavailable'], 409, 'A preparação anterior não pôde ser recuperada e foi preservada.'); + abort_if($snapshot['base']['version_no'] > (int) $record->getAttribute('version_no'), 422, 'A versão analisada não existe nesta declaração.'); + DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'actor_user_id' => $actor->id, + 'entity_type' => $record->getTable(), 'entity_id' => $id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted', + 'after_data' => json_encode(['education_preparation' => ['version' => $current['version'] + 1, 'operation_key' => $key, 'hash' => $hash, + 'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR))]], JSON_THROW_ON_ERROR)]); + + return $this->preparation($actor, $record); + }); + } + + private function preparations(User $actor, LabourDemand|TrainingCapacity $record): Builder + { + return DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id) + ->where('entity_type', $record->getTable())->where('entity_id', $record->id)->where('action', 'review_preparation_saved'); + } + + /** @return array */ + private function preparationRules(mixed $snapshot): array + { + $rules = ['snapshot' => 'required|array:values,generation,base,pending', 'snapshot.values' => 'required|array:version_no,status,review_reason', + 'snapshot.generation' => 'required|array:status,review_reason', 'snapshot.pending' => 'present|nullable|array:values,reasonGeneration,statusGeneration,base']; + $roots = ['snapshot']; + if (is_array($snapshot) && is_array($snapshot['pending'] ?? null)) { + $roots[] = 'snapshot.pending'; + $rules['snapshot.pending.values'] = 'required|array:version_no,status,review_reason,client_key'; + $rules['snapshot.pending.values.client_key'] = 'required|uuid'; + foreach (['reasonGeneration', 'statusGeneration'] as $field) { + $rules['snapshot.pending.'.$field] = 'required|integer|min:0|max:1000000000'; + } + } + foreach (['status', 'review_reason'] as $field) { + $rules['snapshot.generation.'.$field] = 'required|integer|min:0|max:1000000000'; + } + foreach ($roots as $root) { + $rules[$root.'.values.version_no'] = 'required|integer|min:1'; + $rules[$root.'.values.status'] = 'required|in:reviewed,rejected'; + $rules[$root.'.values.review_reason'] = 'present|string|max:3000'; + $rules[$root.'.base'] = 'required|array:version_no,qualification,quantity,status,period_start,period_end,valid_until,source,reference_date,employer,references'; + $rules[$root.'.base.version_no'] = 'required|integer|min:1'; + $rules[$root.'.base.quantity'] = 'required|integer|min:0'; + foreach (['qualification' => 250, 'status' => 40, 'period_start' => 40, 'period_end' => 40, 'valid_until' => 40, 'source' => 5000, 'reference_date' => 40, 'employer' => 1000] as $field => $max) { + $rules[$root.'.base.'.$field] = 'present|string|max:'.$max; + } + $rules[$root.'.base.references'] = 'present|array:occupation,skill,project,course,location'; + $rules[$root.'.base.references.*'] = 'nullable|string|max:1000'; + } + + return $rules; + } + /** @return array{actor_id:int,commands:list} */ public function context(User $actor, LabourDemand|TrainingCapacity $record): array { diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index b51ddc69..a8c98198 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,3 +1,11 @@ +## 11/11/2026 — retoma durável da revisão de Educação em QA + +A revisão interna de procura e capacidade pode guardar e recuperar a preparação privada, incluindo notas, versão analisada e intenção de decisão incerta. Reutiliza os eventos de auditoria existentes, com conteúdo cifrado e isolamento por conta, tipo e declaração. Guardar não decide: a decisão continua pelo escritor nativo, com revisão independente e validação de versão. A intenção fica persistida antes do envio; uma resposta perdida recupera a mesma chave, sem duplicar a decisão. Edições posteriores e preparações mais recentes noutra janela não são substituídas silenciosamente. + +**QA concluído, ainda não instalado:** cinco testes nativos/105 asserções e dez testes novos de interface, com os 17 afectados preservados; análise PHP e tipos aprovados. A página nativa recuperada foi exercitada no componente real em harness: recibo reconhecido, edição posterior conservada e zero envios adicionais. Prova `outputs/education-review-preparation-20261111/verification.json`. Isto não constitui conferência visual no navegador. Sem migração, tabela, papel ou concessão nova. + +**Saldo:** falta entregar este delta pelo processo Git e conferir a apresentação no uso normal. Jornadas externas de estudantes, formadoras e empregadores, representação e consentimento documentados permanecem pendentes; Educação não é declarada integralmente concluída. A homologação mantém `9c067d6a` e as 270 instituições publicadas. Permanecem explícitos os resíduos fiscais L1–L7/restante L8/L9–L16 e os restantes módulos. As provas aceites não foram reabertas. + ## 11/11/2026 — combinação CBF15/créditos/PEC instalada em9c067d6a Instalação expressamente autorizada nesta retoma e executada pelo processoGit existente: `9c067d6ae89c0cd1e9ad4f2813e84048988d3319`, conferida às07:43:38Africa/Johannesburg(05:43:38UTC). Dezficheiros do delta conferidos por hash; revisão,entrada,login,up,Concursos,fundos e simuladorHTTP200; configuração preservada, zero migrações/importações. Backup/restauro isolado conferido:190tabelas e369ficheiros. Nenhum restauro sobre dados activos. diff --git a/docs/investmoz/v2/auditoria-portal/backlog.md b/docs/investmoz/v2/auditoria-portal/backlog.md index 7a299663..efdebfd6 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog.md +++ b/docs/investmoz/v2/auditoria-portal/backlog.md @@ -1,3 +1,11 @@ +## 11/11/2026 — retoma durável da revisão de Educação em QA + +A revisão interna de procura e capacidade pode guardar e recuperar a preparação privada, incluindo notas, versão analisada e intenção de decisão incerta. Reutiliza os eventos de auditoria existentes, com conteúdo cifrado e isolamento por conta, tipo e declaração. Guardar não decide: a decisão continua pelo escritor nativo, com revisão independente e validação de versão. A intenção fica persistida antes do envio; uma resposta perdida recupera a mesma chave, sem duplicar a decisão. Edições posteriores e preparações mais recentes noutra janela não são substituídas silenciosamente. + +**QA concluído, ainda não instalado:** cinco testes nativos/105 asserções e dez testes novos de interface, com os 17 afectados preservados; análise PHP e tipos aprovados. A página nativa recuperada foi exercitada no componente real em harness: recibo reconhecido, edição posterior conservada e zero envios adicionais. Prova `outputs/education-review-preparation-20261111/verification.json`. Isto não constitui conferência visual no navegador. Sem migração, tabela, papel ou concessão nova. + +**Saldo:** falta entregar este delta pelo processo Git e conferir a apresentação no uso normal. Jornadas externas de estudantes, formadoras e empregadores, representação e consentimento documentados permanecem pendentes; Educação não é declarada integralmente concluída. A homologação mantém `9c067d6a` e as 270 instituições publicadas. Permanecem explícitos os resíduos fiscais L1–L7/restante L8/L9–L16 e os restantes módulos. As provas aceites não foram reabertas. + ## 11/11/2026 — combinação CBF15/créditos/PEC instalada em9c067d6a Instalação expressamente autorizada nesta retoma e executada pelo processoGit existente: `9c067d6ae89c0cd1e9ad4f2813e84048988d3319`, conferida às07:43:38Africa/Johannesburg(05:43:38UTC). Dezficheiros do delta conferidos por hash; revisão,entrada,login,up,Concursos,fundos e simuladorHTTP200; configuração preservada, zero migrações/importações. Backup/restauro isolado conferido:190tabelas e369ficheiros. Nenhum restauro sobre dados activos. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index 2c826c22..2083fc51 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -6038,17 +6038,17 @@ B8-FORMATS-ACCESS-REVOCATION"," | QA56: upload próprio em quarentena423→job S [B8 QA 13/10/2026] Somente oito percursos visuaisL4/C135 e consolidação/backup-restauroQA da revisãofixa restam na checklistB; não há exigência técnica genérica nova. Requisito agregado continua parcial; critérios foraB e ativação separados.","2–8 h de reconciliação/ensaio por consumidor agrupado, pressupondo dados sintéticos/perfis preparados. Alteração: estimar após reprodução concreta; variante integral pode exceder esta faixa.",,, AUD-EDU-01,Educação: investimento e procura/capacidade com rascunhos por ator,"Pedido direto 09/10/2026, auditoria §1–4; fontes específicas descritas em relatorio-executivo.md",educação,/educacao; /management/education; APIs skills-edu,"Cursos, necessidades/capacidades, atores, catálogo e políticas existentes. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual. [EDUCATION-DRAFTS-20261102] Núcleo/API de rascunhos incompletos por actor/tipo, cifragem, versão/idempotência, recuperação própria, snapshot e submissão única com regras existentes. EducationDeclarationDrafts/EducationDeclarationRules e /management/education/drafts/{kind}. 14 testes/291 asserções e PHPStan/Pint aprovados; recibo education_drafts_increment_20261102 e manual. Interface, restantes painéis/perfis, concorrência e visual pendentes; parcial local sem instalação no activo. [EDUCATION-DRAFTS-UI-20261102] Rascunhos ligados aos formulários existentes: guardar incompletos, lista privada paginada, recuperação por ID, campos/local preservados, conflito sem sobrescrever, submissão pela identidade e snapshot só de leitura. Recarga parcial conserva outros formulários. HTTP no-store/histórico cifrado. 14 testes/333 asserções, cinco frontend, PHPStan/tipos/lint/Pint/compilação aprovados. Recibo education_drafts_ui_increment_20261102 e manual. Concorrência, visual, restantes perfis/painéis e disponibilização pendentes; parcial local. -[EDUCATION-WORK-QUEUE-20261103] Percurso interno dos gestores com acompanhamento pessoal, detalhe e revisão contextual/versionada; mandatos externos não inferidos.","[INSTALADO-c359364d-20261110] Educacao/continuidade e fraccao nacional internacional ordinaria instaladas e HTTPS verificadas,backup/restauro190tabelas/369ficheiros. Novo delta de quota economica apenas QA,nao instalado. | Historico: [CONCLUSAO-INTEGRAL-20261110] Fontes até ff726e57 na homologação teste-investidor. Não confundir instalação do código com nova concessão, habilitação nominal ou prova visual integral. | Histórico anterior preservado: Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","outputs/investmoz-audit-current-20261012/reused-evidence-integrity.json: 35 recibos anteriores com hashes correspondentes; o âmbito de cada recibo permanece limitado ao critério ensaiado. outputs/investmoz-audit-current-20261012/http-promoter.json; http-validator.json; http-publisher.json; http-manager.json. [EDUCATION-DRAFTS-20261102] Núcleo/API de rascunhos incompletos por actor/tipo, cifragem, versão/idempotência, recuperação própria, snapshot e submissão única com regras existentes. EducationDeclarationDrafts/EducationDeclarationRules e /management/education/drafts/{kind}. 14 testes/291 asserções e PHPStan/Pint aprovados; recibo education_drafts_increment_20261102 e manual. Interface, restantes painéis/perfis, concorrência e visual pendentes; parcial local sem instalação no activo. [EDUCATION-DRAFTS-UI-20261102] Rascunhos ligados aos formulários existentes: guardar incompletos, lista privada paginada, recuperação por ID, campos/local preservados, conflito sem sobrescrever, submissão pela identidade e snapshot só de leitura. Recarga parcial conserva outros formulários. HTTP no-store/histórico cifrado. 14 testes/333 asserções, cinco frontend, PHPStan/tipos/lint/Pint/compilação aprovados. Recibo education_drafts_ui_increment_20261102 e manual. Concorrência, visual, restantes perfis/painéis e disponibilização pendentes; parcial local. [EDUCATION-CONCURRENCY-20261102] Dez cenários com dois processos/bloqueios observados aprovaram criação repetida, edições divergentes, submissão repetida e ambas as precedências gravação/submissão para procura e capacidade. Seis rascunhos/quatro declarações, snapshots, autor e auditoria conferidos; revisão independente mantida. App sem delta; provas 14 Feature/333 e cinco frontend reutilizadas após conferir hashes. Recibo education_drafts_concurrency_increment_20261102. Não é teste de carga ou todas as intercalações; visual, restantes perfis/painéis e disponibilização pendentes. +[EDUCATION-WORK-QUEUE-20261103] Percurso interno dos gestores com acompanhamento pessoal, detalhe e revisão contextual/versionada; mandatos externos não inferidos.","[EDUCATION-REVIEW-PREPARATION-20261111] Retoma durável privada implementada em QA, ainda não instalada; homologação9c067d6a preservada. | Histórico: [INSTALADO-c359364d-20261110] Educacao/continuidade e fraccao nacional internacional ordinaria instaladas e HTTPS verificadas,backup/restauro190tabelas/369ficheiros. Novo delta de quota economica apenas QA,nao instalado. | Historico: [CONCLUSAO-INTEGRAL-20261110] Fontes até ff726e57 na homologação teste-investidor. Não confundir instalação do código com nova concessão, habilitação nominal ou prova visual integral. | Histórico anterior preservado: Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","outputs/investmoz-audit-current-20261012/reused-evidence-integrity.json: 35 recibos anteriores com hashes correspondentes; o âmbito de cada recibo permanece limitado ao critério ensaiado. outputs/investmoz-audit-current-20261012/http-promoter.json; http-validator.json; http-publisher.json; http-manager.json. [EDUCATION-DRAFTS-20261102] Núcleo/API de rascunhos incompletos por actor/tipo, cifragem, versão/idempotência, recuperação própria, snapshot e submissão única com regras existentes. EducationDeclarationDrafts/EducationDeclarationRules e /management/education/drafts/{kind}. 14 testes/291 asserções e PHPStan/Pint aprovados; recibo education_drafts_increment_20261102 e manual. Interface, restantes painéis/perfis, concorrência e visual pendentes; parcial local sem instalação no activo. [EDUCATION-DRAFTS-UI-20261102] Rascunhos ligados aos formulários existentes: guardar incompletos, lista privada paginada, recuperação por ID, campos/local preservados, conflito sem sobrescrever, submissão pela identidade e snapshot só de leitura. Recarga parcial conserva outros formulários. HTTP no-store/histórico cifrado. 14 testes/333 asserções, cinco frontend, PHPStan/tipos/lint/Pint/compilação aprovados. Recibo education_drafts_ui_increment_20261102 e manual. Concorrência, visual, restantes perfis/painéis e disponibilização pendentes; parcial local. [EDUCATION-CONCURRENCY-20261102] Dez cenários com dois processos/bloqueios observados aprovaram criação repetida, edições divergentes, submissão repetida e ambas as precedências gravação/submissão para procura e capacidade. Seis rascunhos/quatro declarações, snapshots, autor e auditoria conferidos; revisão independente mantida. App sem delta; provas 14 Feature/333 e cinco frontend reutilizadas após conferir hashes. Recibo education_drafts_concurrency_increment_20261102. Não é teste de carga ou todas as intercalações; visual, restantes perfis/painéis e disponibilização pendentes. [EDUCATION-WORK-QUEUE-20261103] [EDUCATION-WORK-QUEUE-20261103] Painel interno dos gestores com acompanhamento próprio, revisão independente e detalhe contextual. Revisão fixa a versão analisada, exige comparação se mudou, conserva edições posteriores e repete a mesma tentativa sem nova decisão. Fundamentos anteriores/novos preservados em snapshot cifrado e consulta privada paginada. 30 Feature/629 asserções (16 novos,14 anteriores);22 frontend (17 novos,cinco anteriores). PHPStan/Pint/tipos/formato/lint/build aprovados;54 tabelas QA vazias, owner/RLS/lock conferidos.79 recibos/289 provas anteriores preservados. Recibo education_work_queue_increment_20261103. Parcial local, sem migração/escritas no activo.",Implementado parcialmente,"02/11/2026: concorrência de rascunhos/submissão verificada no recorte de dez cenários. Restam visual, painéis/perfis restantes e disponibilização; aplicação mantém as competências anteriores. Histórico: 02/11/2026: núcleo e interface de rascunhos/recuperação/submissão implementados e verificados localmente. Restam concorrência, visual, painéis dos restantes perfis e disponibilização; lista privada cobre rascunhos e submissões derivadas, não todas as declarações antigas. Histórico: 02/11/2026: núcleo/API de rascunhos implementado e verificado; falta ligação aos formulários e apresentação/recuperação visual, restantes painéis/perfis e concorrência. Histórico: Catálogo/serviços parciais; procura/capacidade sem rascunho incompleto e painéis por ator não implementados. Preparar backlog, sem acrescentar jornadas nesta auditoria. [EDUCATION-WORK-QUEUE-20261103] Gestor interno agora implementado no recorte descrito; rascunhos já aceites preservados. Outros três actores e visual/entrega continuam pendentes.",Novas jornadas não são iniciadas nesta auditoria. Dados reais/perfis/rascunho incompleto pendentes. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,AUD-EDU-01: Criar/editar/recuperar; distinguir vagas/trabalhadores; acesso e fonte; sem autoacreditação,P1,AUD-EDU-01: Relatórios históricos mantidos; estado ativo apurado por leitura nesta auditoria,AUD-EDU-01,1,"[{""id"":""AUD-EDU-01"",""pedido"":""Educação: investimento e procura/capacidade com rascunhos por ator"",""fontes"":[""Pedido direto 09/10/2026, auditoria §1–4; fontes específicas descritas em relatorio-executivo.md""],""aceitacao"":""Criar/editar/recuperar; distinguir vagas/trabalhadores; acesso e fonte; sem autoacreditação"",""estado"":""parcial"",""variantes"":""Relatórios históricos mantidos; estado ativo apurado por leitura nesta auditoria"",""modulo"":""educação""}]",Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,labour_demands: 0; training_capacities: 0; education_opportunities: 0; education_investment_proposals: 0,"Capacidades/provas específicas anteriores existem, mas o requisito agregado ainda tem os critérios restantes explicitados; fechos parciais preservados. -[RECONCILIACAO-43-20261019] 20: Gestão /management/education bloqueada por gate global e cadastro vazio. Faltam rascunhos incompletos e jornadas/painéis educativos por actor.","[INSTALADO-c359364d-20261110] Continuidade da revisao ao paginar historico instalada;restam retoma duravel,jornadas externas/vinculos documentados e conferencia visual. | Historico: [CONCLUSAO-INTEGRAL-20261110] Declarações, rascunhos/submissão, revisão independente/fila e histórico preservados. Novo delta local: paginação do histórico conserva fundamento/envio incerto, exige comparação de versão nova e separa actores; quatro verificações novas aprovadas, ainda sem instalação. Restam retoma durável da preparação e jornadas externas/representação sem cadastro/documento aprovado; visual pendente. | Histórico anterior preservado: 02/11/2026: concorrência de rascunhos/submissão verificada no recorte de dez cenários. Restam visual, painéis/perfis restantes e disponibilização; aplicação mantém as competências anteriores. Histórico: 02/11/2026: núcleo e interface de rascunhos/recuperação/submissão implementados e verificados localmente. Restam concorrência, visual, painéis dos restantes perfis e disponibilização; lista privada cobre rascunhos e submissões derivadas, não todas as declarações antigas. Histórico: 02/11/2026: núcleo/API de rascunhos implementado e verificado; falta ligação aos formulários e apresentação/recuperação visual, restantes painéis/perfis e concorrência. Histórico: Catálogo/serviços parciais; procura/capacidade sem rascunho incompleto e painéis por ator não implementados. Preparar backlog, sem acrescentar jornadas nesta auditoria. +[RECONCILIACAO-43-20261019] 20: Gestão /management/education bloqueada por gate global e cadastro vazio. Faltam rascunhos incompletos e jornadas/painéis educativos por actor.","[EDUCATION-REVIEW-PREPARATION-20261111] Retoma durável da revisão interna encerrada em QA; faltam instalação, visual e jornadas externas/representação documentada. Não encerra Educação inteira. | Histórico: [INSTALADO-c359364d-20261110] Continuidade da revisao ao paginar historico instalada;restam retoma duravel,jornadas externas/vinculos documentados e conferencia visual. | Historico: [CONCLUSAO-INTEGRAL-20261110] Declarações, rascunhos/submissão, revisão independente/fila e histórico preservados. Novo delta local: paginação do histórico conserva fundamento/envio incerto, exige comparação de versão nova e separa actores; quatro verificações novas aprovadas, ainda sem instalação. Restam retoma durável da preparação e jornadas externas/representação sem cadastro/documento aprovado; visual pendente. | Histórico anterior preservado: 02/11/2026: concorrência de rascunhos/submissão verificada no recorte de dez cenários. Restam visual, painéis/perfis restantes e disponibilização; aplicação mantém as competências anteriores. Histórico: 02/11/2026: núcleo e interface de rascunhos/recuperação/submissão implementados e verificados localmente. Restam concorrência, visual, painéis dos restantes perfis e disponibilização; lista privada cobre rascunhos e submissões derivadas, não todas as declarações antigas. Histórico: 02/11/2026: núcleo/API de rascunhos implementado e verificado; falta ligação aos formulários e apresentação/recuperação visual, restantes painéis/perfis e concorrência. Histórico: Catálogo/serviços parciais; procura/capacidade sem rascunho incompleto e painéis por ator não implementados. Preparar backlog, sem acrescentar jornadas nesta auditoria. [UTILIZACAO-HOMOLOGACAO-20261019] 20: Implementação parcial — Área educativa abre; percurso com rascunho e recuperação noutra sessão continua como desenvolvimento/prova restante. [EDUCATION-WORK-QUEUE-20261103] Restam jornadas externas de estudantes, formadoras e empregadores, representação/consentimento, visual e entrega. O proprietário confirmou que não existe cadastro/documento aprovado desses vínculos. Preparação da revisão ainda em memória; não declarar retoma durável do motivo não confirmado.","Cursos, necessidades/capacidades, atores, catálogo e políticas existentes [EDUCATION-WORK-QUEUE-20261103] Reutiliza declarações, regras, cadastro, rascunhos, acesso interno, PortalAudit/Crypt e auditoria append-only. Indicadores gerais e competências conservados.","Incremento iniciado pelo pedido actual de desenvolvimento autónomo, sem nova regra organizacional ou competência. Novas jornadas não são iniciadas nesta auditoria. Dados reais/perfis/rascunho incompleto pendentes. [EDUCATION-WORK-QUEUE-20261103] Fonte de representação/consentimento externo inexistente segundo resposta do proprietário em03/11; não bloqueia painel interno. Não pressupor mandatos ou consentimentos a partir do schema.","Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente. -[EDUCATION-WORK-QUEUE-20261103] D-EDU-02/06/08 e resposta «nao existe»; somente gestor interno existente. Comparação, cifra, recibo e paginação são escolhas técnicas, sem novas alçadas.",Completar critérios restantes sem repetir provas válidas,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: educação. Esforço de código adicional só após lacuna reproduzida.,"[INSTALADO-c359364d-20261110] outputs/education-l8-delivery-20261110/verification.json. | [CONCLUSAO-INTEGRAL-20261110] Instalação ff726e57: outputs/reviewed-install-20261110/verification.json; fontes aceites e recibos anteriores reutilizados. Saldo por percurso no início de visao-consolidada-modulos.md. outputs/investmoz-audit-current-20261012/reused-evidence-integrity.json: 35 recibos anteriores com hashes correspondentes; o âmbito de cada recibo permanece limitado ao critério ensaiado. outputs/investmoz-audit-current-20261012/http-promoter.json; http-validator.json; http-publisher.json; http-manager.json. | Homologação teste-investidor 19/10/2026: Jornadas educativas=Instalado mas desabilitado. Saldo derivado e limites em outputs/investmoz-disponibilizacao-integral-20261019/SALDO-E-ACTUALIZACAO-CONSOLIDADA.md. Não substitui estado/provas do activo; trecho documental encerrado, vínculo dossier aberto. | Proposta integral fdcb31f1b5b9: aplicação activa local completa + R2/DOC01/destino reconciliados; schema/build/recuperação de contexto e reversão de apresentação ensaiados no clone isolado. Não instalado remotamente. Inc. QA territoriais/Inhambane/programas separados. Limites e decisões em outputs/investmoz-disponibilizacao-integral-20261019/PROPOSTA-INTEGRAL-PARA-DECISAO.md. | Execução autorizada fdcb31f1b5b9 concluída em teste-investidor: Jornadas educativas=Disponível. 1080 hashes iguais, schema171/88, custódia/scan/recuperação reais. Sem publicação ou papéis novos. Vínculo dossier legítimo pendente; incrementos QA separados. Limites em outputs/investmoz-disponibilizacao-integral-20261019/RESULTADO-DISPONIBILIZACAO-INTEGRAL.md; não encerra critérios globais não demonstrados. +[EDUCATION-WORK-QUEUE-20261103] D-EDU-02/06/08 e resposta «nao existe»; somente gestor interno existente. Comparação, cifra, recibo e paginação são escolhas técnicas, sem novas alçadas.",Completar critérios restantes sem repetir provas válidas,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: educação. Esforço de código adicional só após lacuna reproduzida.,"[EDUCATION-REVIEW-PREPARATION-20261111] Cinco testes nativos/105 asserções, dez novos frontend e17 afectados; análise e tipos aprovados. outputs/education-review-preparation-20261111/verification.json: DTO nativo recuperado pelo componente, edições preservadas e zero decisão duplicada. Auditoria cifrada, actor/tipo/ID, versão e chave imutável; sem migração. | Histórico: [INSTALADO-c359364d-20261110] outputs/education-l8-delivery-20261110/verification.json. | [CONCLUSAO-INTEGRAL-20261110] Instalação ff726e57: outputs/reviewed-install-20261110/verification.json; fontes aceites e recibos anteriores reutilizados. Saldo por percurso no início de visao-consolidada-modulos.md. outputs/investmoz-audit-current-20261012/reused-evidence-integrity.json: 35 recibos anteriores com hashes correspondentes; o âmbito de cada recibo permanece limitado ao critério ensaiado. outputs/investmoz-audit-current-20261012/http-promoter.json; http-validator.json; http-publisher.json; http-manager.json. | Homologação teste-investidor 19/10/2026: Jornadas educativas=Instalado mas desabilitado. Saldo derivado e limites em outputs/investmoz-disponibilizacao-integral-20261019/SALDO-E-ACTUALIZACAO-CONSOLIDADA.md. Não substitui estado/provas do activo; trecho documental encerrado, vínculo dossier aberto. | Proposta integral fdcb31f1b5b9: aplicação activa local completa + R2/DOC01/destino reconciliados; schema/build/recuperação de contexto e reversão de apresentação ensaiados no clone isolado. Não instalado remotamente. Inc. QA territoriais/Inhambane/programas separados. Limites e decisões em outputs/investmoz-disponibilizacao-integral-20261019/PROPOSTA-INTEGRAL-PARA-DECISAO.md. | Execução autorizada fdcb31f1b5b9 concluída em teste-investidor: Jornadas educativas=Disponível. 1080 hashes iguais, schema171/88, custódia/scan/recuperação reais. Sem publicação ou papéis novos. Vínculo dossier legítimo pendente; incrementos QA separados. Limites em outputs/investmoz-disponibilizacao-integral-20261019/RESULTADO-DISPONIBILIZACAO-INTEGRAL.md; não encerra critérios globais não demonstrados. [UTILIZACAO-HOMOLOGACAO-20261019] Leitura nominal de contas/recursos no destino; saldo18/10; prévia29 sem escrita; comparação de hashes QA separados. outputs/investmoz-disponibilizacao-integral-20261019/UTILIZACAO-EFECTIVA-HOMOLOGACAO.md @@ -7136,7 +7136,7 @@ API de rejeição com reason ausente e indicação de correção vazia são ensa Reutilização por mesmo consumidor/ação/campo/resultado e destinoQA, não por semelhança ou nome de ficheiro. Novos ramos/dados, licenças e obrigações identificados separadamente. Tentativa automáticaC135 conservada como histórico, não saldo global.","2–8 h de reconciliação/ensaio por consumidor agrupado, pressupondo dados sintéticos/perfis preparados. Alteração: estimar após reprodução concreta; variante integral pode exceder esta faixa.",Navegação; apresentação visual,,"Inicial QA: comandos consolidados; recibo e relatório protegidos HTTP, 11 secções/60 linhas. Contraste, moeda, campos ausentes e tabelas corrigidos; paginação conforme PROVA-PAGINACAO.json. Não encerra todas as rotas." AUD-OP-05,Oito etapas de maturidade no cadastro de oportunidades,"Pedido direto 09/10/2026, principais lacunas e continuação; docs/investmoz/v2/gestao-oportunidades-dashboard.md §10; referências originais conservadas na matriz",oportunidades,/management/opportunities/drafts; /management/opportunities/{id}/edit,"OpportunityManagement, OpportunityCanonicalContract/Response, OpportunityReviewMateriality, PortalProfileAccess, OpportunityResources. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual. [MATURITY-PUBLICATION-20261102] Cadastro versionado e pesquisa consciente da escala já existentes preservados. Corrigida republicação com etapa opcional explicitamente limpa: valor/designação anteriores deixam de ser apresentados e o contexto continua spec-v2 com etapa desconhecida, sem converter o número em nível histórico. Omissão conserva a resposta anterior; se a publicação conserva valores anteriores sem campo de etapa, conserva também os metadados. Histórico anterior fica íntegro. 15 Feature/371 asserções: oito etapas em submissão/revisão independente/publicação, limpeza aprovada e pesquisa por etapa/escala, seis níveis legados e código inválido recusado. Pint/PHPStan aprovados; recibo maturity_publication_increment_20261102. QA sintética isolada, sem alteração no activo.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","outputs/investmoz-audit-current-20261012/reused-evidence-integrity.json: 35 recibos anteriores com hashes correspondentes; o âmbito de cada recibo permanece limitado ao critério ensaiado. outputs/investmoz-audit-current-20261012/http-promoter.json; http-validator.json; http-publisher.json; http-manager.json. [MATURITY-PUBLICATION-20261102] Cadastro versionado e pesquisa consciente da escala já existentes preservados. Corrigida republicação com etapa opcional explicitamente limpa: valor/designação anteriores deixam de ser apresentados e o contexto continua spec-v2 com etapa desconhecida, sem converter o número em nível histórico. Omissão conserva a resposta anterior; se a publicação conserva valores anteriores sem campo de etapa, conserva também os metadados. Histórico anterior fica íntegro. 15 Feature/371 asserções: oito etapas em submissão/revisão independente/publicação, limpeza aprovada e pesquisa por etapa/escala, seis níveis legados e código inválido recusado. Pint/PHPStan aprovados; recibo maturity_publication_increment_20261102. QA sintética isolada, sem alteração no activo.",Implementado parcialmente,"Saldo 02/11/2026: oito etapas e seis níveis históricos conferidos em publicação interna pelo workflow existente; limpeza aprovada já não reclassifica o número anterior. Restam adopção explícita de legados, variantes/categorias ainda não abrangidas, observação visual e disponibilização do delta; prova da pesquisa pública anterior preservada, sem publicação externa inferida pelos novos ensaios. Histórico anterior: 7/8 aceites no draft com catálogo versionado. Restam adoção/revisão/publicação/filtro público na escala correspondente; não reinterpretar seis valores históricos.","030000→040000 já instalados; contratos/resources existentes, sessão/MFA, ClamD e worker para ficheiros; gates das seis categorias ON. Variantes modulares/offers continuam separadas. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.",AUD-OP-05: Catálogo de oito etapas da especificação com versão explícita; guardar/recuperar etapas 7 e 8; preservar interpretação das seis etapas antigas; filtro e ficha coerentes,P1,"AUD-OP-05: O relatório indicado em outputs/auditoria-portal-2026-10-09.md não foi localizado neste workspace; conteúdo da mensagem preservado como fonte direta, sem atribuir hash ao ficheiro não lido",AUD-OP-05,1,"[{""id"":""AUD-OP-05"",""pedido"":""Oito etapas de maturidade no cadastro de oportunidades"",""fontes"":[""Pedido direto 09/10/2026, principais lacunas e continuação; docs/investmoz/v2/gestao-oportunidades-dashboard.md §10; referências originais conservadas na matriz""],""aceitacao"":""Catálogo de oito etapas da especificação com versão explícita; guardar/recuperar etapas 7 e 8; preservar interpretação das seis etapas antigas; filtro e ficha coerentes"",""estado"":""parcial"",""variantes"":""O relatório indicado em outputs/auditoria-portal-2026-10-09.md não foi localizado neste workspace; conteúdo da mensagem preservado como fonte direta, sem atribuir hash ao ficheiro não lido"",""modulo"":""oportunidades""}]",Oito etapas e opção histórica conservada; visual pendente,"POST draft 1–8 e PUT código/versão; inconsistência 422, conflito 409, outro perfil 403",management_draft guarda maturity_stage/level/catalogue_version; colunas históricas intactas; audit_events preservados,"GET edit recupera código, posição, rótulo e versão; catálogo server-side com oito entradas",Props do formulário verificadas; publicação/filtros públicos novos ainda pendentes,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,Consultar snapshot READ ONLY de 16:17:55 UTC; nenhum dado ativo alterado,"Capacidades/provas específicas anteriores existem, mas o requisito agregado ainda tem os critérios restantes explicitados; fechos parciais preservados.","Saldo 02/11/2026: oito etapas e seis níveis históricos conferidos em publicação interna pelo workflow existente; limpeza aprovada já não reclassifica o número anterior. Restam adopção explícita de legados, variantes/categorias ainda não abrangidas, observação visual e disponibilização do delta; prova da pesquisa pública anterior preservada, sem publicação externa inferida pelos novos ensaios. Histórico anterior: 7/8 aceites no draft com catálogo versionado. Restam adoção/revisão/publicação/filtro público na escala correspondente; não reinterpretar seis valores históricos.","OpportunityManagement, OpportunityCanonicalContract/Response, OpportunityReviewMateriality, PortalProfileAccess, OpportunityResources; OpportunityProfileWorkflow/OpportunityMaturity e histórico de publicação existentes; sem catálogo paralelo","030000→040000 já instalados; contratos/resources existentes, sessão/MFA, ClamD e worker para ficheiros; gates das seis categorias ON. Variantes modulares/offers continuam separadas.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,Completar critérios restantes sem repetir provas válidas,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: oportunidades. Esforço de código adicional só após lacuna reproduzida.,"outputs/investmoz-audit-current-20261012/reused-evidence-integrity.json: 35 recibos anteriores com hashes correspondentes; o âmbito de cada recibo permanece limitado ao critério ensaiado. outputs/investmoz-audit-current-20261012/http-promoter.json; http-validator.json; http-publisher.json; http-manager.json. [MATURITY-PUBLICATION-20261102] Cadastro versionado e pesquisa consciente da escala já existentes preservados. Corrigida republicação com etapa opcional explicitamente limpa: valor/designação anteriores deixam de ser apresentados e o contexto continua spec-v2 com etapa desconhecida, sem converter o número em nível histórico. Omissão conserva a resposta anterior; se a publicação conserva valores anteriores sem campo de etapa, conserva também os metadados. Histórico anterior fica íntegro. 15 Feature/371 asserções: oito etapas em submissão/revisão independente/publicação, limpeza aprovada e pesquisa por etapa/escala, seis níveis legados e código inválido recusado. Pint/PHPStan aprovados; recibo maturity_publication_increment_20261102. QA sintética isolada, sem alteração no activo.",oportunidades,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,"Rejeição 422 dos níveis 7/8 corrigida no draft. UI renderizada e adoção/revisão/publicação da nova versão continuam pendentes; filtros públicos continuam explicitamente na escala histórica, sem ler draft privado.",Sem migration adicional: etapa/posição/versão no management_draft existente. Workflow de publicação ainda pendente; nenhuma conversão automática dos seis valores antigos.,OpportunityMaturity: spec-v2 (oito códigos) e legacy-v1 (seis posições); OpportunityManagement; seletor com catálogo recebido do servidor,"PortalAuditNextLotTest; phpunit-lote2-final.log/xml; API/persistência/Inertia demonstradas, UI visual pendente; oito POST/GET; 7/8 201; replay único; 409; divergência código/posição 422; legado 6 mantém rótulo/coluna","POST draft 1–8 e PUT código/versão; inconsistência 422, conflito 409, outro perfil 403",,,,"2–8 h de reconciliação/ensaio por consumidor agrupado, pressupondo dados sintéticos/perfis preparados. Alteração: estimar após reprodução concreta; variante integral pode exceder esta faixa.",,, -AUD-EDU-02,Rascunho incompleto privado de procura e capacidade,"Pedido direto 09/10/2026, principais lacunas e continuação; docs/investmoz/v2/gestao-oportunidades-dashboard.md §10; referências originais conservadas na matriz",educação,/management/education/demands; /management/education/capacities,"EducationController, LabourDemand, TrainingCapacity, atores existentes. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual. [EDUCATION-DRAFT-RECONCILIATION-20261102] AUD-EDU-02 é também coberto pelos incrementos já registados em AUD-EDU-01: rascunho incompleto privado de procura/capacidade, dono/tipo/versão, guarda/recuperação pela interface, conflitos, submissão única e revisão independente; sem zeros/datas fictícios ou inclusão nos agregados. Reutilizadas provas de 14 Feature/333 asserções, cinco frontend e dez cenários reais de concorrência em QA, com hashes das seis fontes UI/serviço/testes e sete logs conferidos em02/11. Recibos education_drafts_ui_increment_20261102 e education_drafts_concurrency_increment_20261102. Não é desenvolvimento novo ou instalação no activo.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","segmentacao-processos-reconciliacao-20261011.md; contratos/rotas inventariados atuais; não há prova de percurso completo deste incremento. [EDUCATION-DRAFT-RECONCILIATION-20261102] AUD-EDU-02 é também coberto pelos incrementos já registados em AUD-EDU-01: rascunho incompleto privado de procura/capacidade, dono/tipo/versão, guarda/recuperação pela interface, conflitos, submissão única e revisão independente; sem zeros/datas fictícios ou inclusão nos agregados. Reutilizadas provas de 14 Feature/333 asserções, cinco frontend e dez cenários reais de concorrência em QA, com hashes das seis fontes UI/serviço/testes e sete logs conferidos em02/11. Recibos education_drafts_ui_increment_20261102 e education_drafts_concurrency_increment_20261102. Não é desenvolvimento novo ou instalação no activo.",Implementado parcialmente,"Saldo02/11/2026: rascunhos incompletos de procura/capacidade implementados e demonstrados tecnicamente em QA. Restam observação visual e disponibilização deste recorte; outros painéis/perfis pertencem a AUD-EDU-03/01. Não repetir implementação ou testes aceites por estado documental antigo. Histórico anterior: Procura/capacidade não têm rascunho incompleto recuperável. Preparar implementação separada, sem acrescentar jornadas nesta auditoria.","Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Formulários incompletos recuperados noutra sessão, validação só na submissão, isolamento por ator. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.","AUD-EDU-02: Guardar incompleto, editar com conflito, recuperar por dono; só submissão exige campos completos; revisão por ator distinto; rascunho fora dos agregados públicos",P1,"AUD-EDU-02: O relatório indicado em outputs/auditoria-portal-2026-10-09.md não foi localizado neste workspace; conteúdo da mensagem preservado como fonte direta, sem atribuir hash ao ficheiro não lido",AUD-EDU-02,1,"[{""id"":""AUD-EDU-02"",""pedido"":""Rascunho incompleto privado de procura e capacidade"",""fontes"":[""Pedido direto 09/10/2026, principais lacunas e continuação; docs/investmoz/v2/gestao-oportunidades-dashboard.md §10; referências originais conservadas na matriz""],""aceitacao"":""Guardar incompleto, editar com conflito, recuperar por dono; só submissão exige campos completos; revisão por ator distinto; rascunho fora dos agregados públicos"",""estado"":""não implementado"",""variantes"":""O relatório indicado em outputs/auditoria-portal-2026-10-09.md não foi localizado neste workspace; conteúdo da mensagem preservado como fonte direta, sem atribuir hash ao ficheiro não lido"",""modulo"":""educação""}]",Código inspecionado; browser pendente,QA reproduz 422; critério pedido continua a falhar,Nenhuma gravação nas tentativas incompletas/etapas 7–8; hashes conservados,Percurso solicitado ainda incompleto; não promover a concluído,Falha/lacuna documentada; inspeção visual pendente,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,não implementado,Consultar snapshot READ ONLY de 16:17:55 UTC; nenhum dado ativo alterado,Diagnóstico específico anterior de ausência/cobertura conservado; inventário atual não demonstra entrega posterior deste incremento. Nenhuma nova implementação iniciada nesta auditoria.,"Saldo02/11/2026: rascunhos incompletos de procura/capacidade implementados e demonstrados tecnicamente em QA. Restam observação visual e disponibilização deste recorte; outros painéis/perfis pertencem a AUD-EDU-03/01. Não repetir implementação ou testes aceites por estado documental antigo. Histórico anterior: Procura/capacidade não têm rascunho incompleto recuperável. Preparar implementação separada, sem acrescentar jornadas nesta auditoria.","EducationController, LabourDemand, TrainingCapacity, atores existentes; EducationDeclarationDrafts, EducationDeclarationRules, migration de rascunhos, formulários e snapshots já desenvolvidos","Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Formulários incompletos recuperados noutra sessão, validação só na submissão, isolamento por ator.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,"Nova funcionalidade/expansão; preparar execução, não implementar nesta auditoria",Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: incrementos futuros. Esforço de código adicional só após lacuna reproduzida.,"segmentacao-processos-reconciliacao-20261011.md; contratos/rotas inventariados atuais; não há prova de percurso completo deste incremento. [EDUCATION-DRAFT-RECONCILIATION-20261102] AUD-EDU-02 é também coberto pelos incrementos já registados em AUD-EDU-01: rascunho incompleto privado de procura/capacidade, dono/tipo/versão, guarda/recuperação pela interface, conflitos, submissão única e revisão independente; sem zeros/datas fictícios ou inclusão nos agregados. Reutilizadas provas de 14 Feature/333 asserções, cinco frontend e dez cenários reais de concorrência em QA, com hashes das seis fontes UI/serviço/testes e sete logs conferidos em02/11. Recibos education_drafts_ui_increment_20261102 e education_drafts_concurrency_increment_20261102. Não é desenvolvimento novo ou instalação no activo.",incrementos futuros,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,Procura/capacidade incompletas recebem 422 sem gravação; não há percurso de rascunho incompleto. Rascunho de proposta de investimento e curso não satisfaz este pedido.,Executável em QA sem ClamD nem nova aceitação de fontes; dados/designações concretos apenas para utilização institucional ativa,EducationController::store exige todos os campos e grava submitted; LabourDemand/TrainingCapacity existentes,pending-actions.json: quatro pedidos 422 na cópia restaurada; hashes de oportunidades/procura/capacidade/histórico inalterados; browser pendente,QA reproduz 422; critério pedido continua a falhar,,,,"16–32 h de implementação/ensaio, após 2–4 h de definição dos campos; não inclui novas jornadas.",,, +AUD-EDU-02,Rascunho incompleto privado de procura e capacidade,"Pedido direto 09/10/2026, principais lacunas e continuação; docs/investmoz/v2/gestao-oportunidades-dashboard.md §10; referências originais conservadas na matriz",educação,/management/education/demands; /management/education/capacities,"EducationController, LabourDemand, TrainingCapacity, atores existentes. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual. [EDUCATION-DRAFT-RECONCILIATION-20261102] AUD-EDU-02 é também coberto pelos incrementos já registados em AUD-EDU-01: rascunho incompleto privado de procura/capacidade, dono/tipo/versão, guarda/recuperação pela interface, conflitos, submissão única e revisão independente; sem zeros/datas fictícios ou inclusão nos agregados. Reutilizadas provas de 14 Feature/333 asserções, cinco frontend e dez cenários reais de concorrência em QA, com hashes das seis fontes UI/serviço/testes e sete logs conferidos em02/11. Recibos education_drafts_ui_increment_20261102 e education_drafts_concurrency_increment_20261102. Não é desenvolvimento novo ou instalação no activo.","[EDUCATION-REVIEW-PREPARATION-20261111] Retoma durável privada implementada em QA, ainda não instalada; homologação9c067d6a preservada. | Histórico: Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","segmentacao-processos-reconciliacao-20261011.md; contratos/rotas inventariados atuais; não há prova de percurso completo deste incremento. [EDUCATION-DRAFT-RECONCILIATION-20261102] AUD-EDU-02 é também coberto pelos incrementos já registados em AUD-EDU-01: rascunho incompleto privado de procura/capacidade, dono/tipo/versão, guarda/recuperação pela interface, conflitos, submissão única e revisão independente; sem zeros/datas fictícios ou inclusão nos agregados. Reutilizadas provas de 14 Feature/333 asserções, cinco frontend e dez cenários reais de concorrência em QA, com hashes das seis fontes UI/serviço/testes e sete logs conferidos em02/11. Recibos education_drafts_ui_increment_20261102 e education_drafts_concurrency_increment_20261102. Não é desenvolvimento novo ou instalação no activo.",Implementado parcialmente,"Saldo02/11/2026: rascunhos incompletos de procura/capacidade implementados e demonstrados tecnicamente em QA. Restam observação visual e disponibilização deste recorte; outros painéis/perfis pertencem a AUD-EDU-03/01. Não repetir implementação ou testes aceites por estado documental antigo. Histórico anterior: Procura/capacidade não têm rascunho incompleto recuperável. Preparar implementação separada, sem acrescentar jornadas nesta auditoria.","Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Formulários incompletos recuperados noutra sessão, validação só na submissão, isolamento por ator. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.","AUD-EDU-02: Guardar incompleto, editar com conflito, recuperar por dono; só submissão exige campos completos; revisão por ator distinto; rascunho fora dos agregados públicos",P1,"AUD-EDU-02: O relatório indicado em outputs/auditoria-portal-2026-10-09.md não foi localizado neste workspace; conteúdo da mensagem preservado como fonte direta, sem atribuir hash ao ficheiro não lido",AUD-EDU-02,1,"[{""id"":""AUD-EDU-02"",""pedido"":""Rascunho incompleto privado de procura e capacidade"",""fontes"":[""Pedido direto 09/10/2026, principais lacunas e continuação; docs/investmoz/v2/gestao-oportunidades-dashboard.md §10; referências originais conservadas na matriz""],""aceitacao"":""Guardar incompleto, editar com conflito, recuperar por dono; só submissão exige campos completos; revisão por ator distinto; rascunho fora dos agregados públicos"",""estado"":""não implementado"",""variantes"":""O relatório indicado em outputs/auditoria-portal-2026-10-09.md não foi localizado neste workspace; conteúdo da mensagem preservado como fonte direta, sem atribuir hash ao ficheiro não lido"",""modulo"":""educação""}]",Código inspecionado; browser pendente,QA reproduz 422; critério pedido continua a falhar,Nenhuma gravação nas tentativas incompletas/etapas 7–8; hashes conservados,Percurso solicitado ainda incompleto; não promover a concluído,Falha/lacuna documentada; inspeção visual pendente,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,não implementado,Consultar snapshot READ ONLY de 16:17:55 UTC; nenhum dado ativo alterado,Diagnóstico específico anterior de ausência/cobertura conservado; inventário atual não demonstra entrega posterior deste incremento. Nenhuma nova implementação iniciada nesta auditoria.,"[EDUCATION-REVIEW-PREPARATION-20261111] Retoma durável da revisão interna encerrada em QA; faltam instalação, visual e jornadas externas/representação documentada. Não encerra Educação inteira. | Histórico: Saldo02/11/2026: rascunhos incompletos de procura/capacidade implementados e demonstrados tecnicamente em QA. Restam observação visual e disponibilização deste recorte; outros painéis/perfis pertencem a AUD-EDU-03/01. Não repetir implementação ou testes aceites por estado documental antigo. Histórico anterior: Procura/capacidade não têm rascunho incompleto recuperável. Preparar implementação separada, sem acrescentar jornadas nesta auditoria.","EducationController, LabourDemand, TrainingCapacity, atores existentes; EducationDeclarationDrafts, EducationDeclarationRules, migration de rascunhos, formulários e snapshots já desenvolvidos","Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Formulários incompletos recuperados noutra sessão, validação só na submissão, isolamento por ator.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,"Nova funcionalidade/expansão; preparar execução, não implementar nesta auditoria",Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: incrementos futuros. Esforço de código adicional só após lacuna reproduzida.,"[EDUCATION-REVIEW-PREPARATION-20261111] Cinco testes nativos/105 asserções, dez novos frontend e17 afectados; análise e tipos aprovados. outputs/education-review-preparation-20261111/verification.json: DTO nativo recuperado pelo componente, edições preservadas e zero decisão duplicada. Auditoria cifrada, actor/tipo/ID, versão e chave imutável; sem migração. | Histórico: segmentacao-processos-reconciliacao-20261011.md; contratos/rotas inventariados atuais; não há prova de percurso completo deste incremento. [EDUCATION-DRAFT-RECONCILIATION-20261102] AUD-EDU-02 é também coberto pelos incrementos já registados em AUD-EDU-01: rascunho incompleto privado de procura/capacidade, dono/tipo/versão, guarda/recuperação pela interface, conflitos, submissão única e revisão independente; sem zeros/datas fictícios ou inclusão nos agregados. Reutilizadas provas de 14 Feature/333 asserções, cinco frontend e dez cenários reais de concorrência em QA, com hashes das seis fontes UI/serviço/testes e sete logs conferidos em02/11. Recibos education_drafts_ui_increment_20261102 e education_drafts_concurrency_increment_20261102. Não é desenvolvimento novo ou instalação no activo.",incrementos futuros,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,Procura/capacidade incompletas recebem 422 sem gravação; não há percurso de rascunho incompleto. Rascunho de proposta de investimento e curso não satisfaz este pedido.,Executável em QA sem ClamD nem nova aceitação de fontes; dados/designações concretos apenas para utilização institucional ativa,EducationController::store exige todos os campos e grava submitted; LabourDemand/TrainingCapacity existentes,pending-actions.json: quatro pedidos 422 na cópia restaurada; hashes de oportunidades/procura/capacidade/histórico inalterados; browser pendente,QA reproduz 422; critério pedido continua a falhar,,,,"16–32 h de implementação/ensaio, após 2–4 h de definição dos campos; não inclui novas jornadas.",,, AUD-EDU-03,Jornadas e painéis educativos por ator,"Pedido direto 09/10/2026, principais lacunas e continuação; docs/investmoz/v2/gestao-oportunidades-dashboard.md §10; referências originais conservadas na matriz",educação,/educacao; /management/education,"Catálogo, entidades e políticas educativas existentes. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual. [EDUCATION-WORK-QUEUE-20261103] Percurso interno dos gestores com acompanhamento pessoal, detalhe e revisão contextual/versionada; mandatos externos não inferidos.","[INSTALADO-c359364d-20261110] Educacao/continuidade e fraccao nacional internacional ordinaria instaladas e HTTPS verificadas,backup/restauro190tabelas/369ficheiros. Novo delta de quota economica apenas QA,nao instalado. | Historico: [CONCLUSAO-INTEGRAL-20261110] Fontes até ff726e57 na homologação teste-investidor. Não confundir instalação do código com nova concessão, habilitação nominal ou prova visual integral. | Histórico anterior preservado: Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","segmentacao-processos-reconciliacao-20261011.md; contratos/rotas inventariados atuais; não há prova de percurso completo deste incremento. [EDUCATION-WORK-QUEUE-20261103] [EDUCATION-WORK-QUEUE-20261103] Painel interno dos gestores com acompanhamento próprio, revisão independente e detalhe contextual. Revisão fixa a versão analisada, exige comparação se mudou, conserva edições posteriores e repete a mesma tentativa sem nova decisão. Fundamentos anteriores/novos preservados em snapshot cifrado e consulta privada paginada. 30 Feature/629 asserções (16 novos,14 anteriores);22 frontend (17 novos,cinco anteriores). PHPStan/Pint/tipos/formato/lint/build aprovados;54 tabelas QA vazias, owner/RLS/lock conferidos.79 recibos/289 provas anteriores preservados. Recibo education_work_queue_increment_20261103. Parcial local, sem migração/escritas no activo.",Implementado parcialmente,"Painéis e jornadas educativos completos por ator não implementados. diff --git a/docs/investmoz/v2/auditoria-portal/visao-consolidada-modulos.md b/docs/investmoz/v2/auditoria-portal/visao-consolidada-modulos.md index 01296b89..12a7c35d 100644 --- a/docs/investmoz/v2/auditoria-portal/visao-consolidada-modulos.md +++ b/docs/investmoz/v2/auditoria-portal/visao-consolidada-modulos.md @@ -1,3 +1,11 @@ +## 11/11/2026 — retoma durável da revisão de Educação em QA + +A revisão interna de procura e capacidade pode guardar e recuperar a preparação privada, incluindo notas, versão analisada e intenção de decisão incerta. Reutiliza os eventos de auditoria existentes, com conteúdo cifrado e isolamento por conta, tipo e declaração. Guardar não decide: a decisão continua pelo escritor nativo, com revisão independente e validação de versão. A intenção fica persistida antes do envio; uma resposta perdida recupera a mesma chave, sem duplicar a decisão. Edições posteriores e preparações mais recentes noutra janela não são substituídas silenciosamente. + +**QA concluído, ainda não instalado:** cinco testes nativos/105 asserções e dez testes novos de interface, com os 17 afectados preservados; análise PHP e tipos aprovados. A página nativa recuperada foi exercitada no componente real em harness: recibo reconhecido, edição posterior conservada e zero envios adicionais. Prova `outputs/education-review-preparation-20261111/verification.json`. Isto não constitui conferência visual no navegador. Sem migração, tabela, papel ou concessão nova. + +**Saldo:** falta entregar este delta pelo processo Git e conferir a apresentação no uso normal. Jornadas externas de estudantes, formadoras e empregadores, representação e consentimento documentados permanecem pendentes; Educação não é declarada integralmente concluída. A homologação mantém `9c067d6a` e as 270 instituições publicadas. Permanecem explícitos os resíduos fiscais L1–L7/restante L8/L9–L16 e os restantes módulos. As provas aceites não foram reabertas. + ## 11/11/2026 — combinação CBF15/créditos/PEC instalada em9c067d6a Instalação expressamente autorizada nesta retoma e executada pelo processoGit existente: `9c067d6ae89c0cd1e9ad4f2813e84048988d3319`, conferida às07:43:38Africa/Johannesburg(05:43:38UTC). Dezficheiros do delta conferidos por hash; revisão,entrada,login,up,Concursos,fundos e simuladorHTTP200; configuração preservada, zero migrações/importações. Backup/restauro isolado conferido:190tabelas e369ficheiros. Nenhum restauro sobre dados activos. diff --git a/resources/js/components/education-review.tsx b/resources/js/components/education-review.tsx index e63c9449..3cd84c61 100644 --- a/resources/js/components/education-review.tsx +++ b/resources/js/components/education-review.tsx @@ -16,6 +16,46 @@ export default function EducationReview(props: EducationReviewProps) { aria-label="Revisão interna da declaração" > {edit.notice &&

{edit.notice}

} + {edit.preparationAvailable && ( +
+

+ Guarde a preparação para retomar noutra entrada. É + privada desta conta e declaração; guardar não decide nem + aprova a declaração. +

+ {edit.savedPreparation?.version ? ( +

+ Preparação guardada: versão{' '} + {edit.savedPreparation.version}. +

+ ) : ( +

Ainda sem preparação privada guardada.

+ )} + + + {edit.persisting && ( +

A conferir a preparação privada…

+ )} +
+ )} + {(edit.stale || edit.conflict) && (
{edit.uncertain && (

- A recuperação repete a decisão enviada:{' '} + A recuperação mantém a mesma intenção de revisão:{' '} {states[edit.sent?.status ?? ''] ?? 'Por confirmar'} . Fundamento: {edit.sent?.review_reason}. As edições posteriores ficam conservadas. diff --git a/resources/js/lib/education-review.ts b/resources/js/lib/education-review.ts index 8ec388b3..8695c935 100644 --- a/resources/js/lib/education-review.ts +++ b/resources/js/lib/education-review.ts @@ -1,6 +1,7 @@ import { router, useForm } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; import { createOperationKey } from './operation-key'; +import { profileJson } from './profile-json'; export type EducationDeclaration = { id: string; @@ -29,6 +30,7 @@ export type EducationReviewProps = { kind: 'demands' | 'capacities'; actorId: number; today: string; + preparation?: ReviewPreparation; references?: Record; }; type Values = { version_no: number; status: string; review_reason: string }; @@ -53,6 +55,27 @@ type Attempt = { statusGeneration: number; base: Snapshot; }; +export type ReviewPreparationSnapshot = { + values: Values; + generation: { status: number; review_reason: number }; + base: Snapshot; + pending: Attempt | null; +}; +export type ReviewPreparation = { + actor_id: number; + kind: 'demands' | 'capacities'; + record_id: string; + version: number; + operation_key: string | null; + snapshot: ReviewPreparationSnapshot | null; + unavailable: boolean; +}; +type PreparationSave = { + actor_id: number; + version: number; + operation_key: string; + snapshot: ReviewPreparationSnapshot; +}; const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const positive = (value: unknown): value is number => @@ -79,6 +102,7 @@ export function useEducationReview({ kind, actorId, references = {}, + preparation, }: EducationReviewProps) { const form = useForm({ version_no: row.version_no, @@ -91,6 +115,8 @@ export function useEducationReview({ pending = useRef(null), alive = useRef(true); const generation = useRef({ status: 0, review_reason: 0 }); + const observed = useRef({ row, references }); + observed.current = { row, references }; const [uncertain, setUncertain] = useState(false), [conflict, setConflict] = useState(false), [refreshing, setRefreshing] = useState(false), @@ -102,6 +128,229 @@ export function useEducationReview({ alive.current = false; }; }, []); + const [savedPreparation, setSavedPreparation] = useState( + preparation ?? null, + ); + const [persisting, setPersisting] = useState(false); + const preparationVersion = useRef(preparation?.version ?? 0); + const preparationSave = useRef(null); + const saving = useRef(false); + const preparationUrl = `/management/education/${kind}/${row.id}/review-preparation`; + function privateSnapshot(): ReviewPreparationSnapshot { + return structuredClone({ + values: live.current, + generation: generation.current, + base: base.current, + pending: pending.current, + }); + } + function validPreparation(value: unknown): value is ReviewPreparation { + const nonnegative = (n: unknown): n is number => + typeof n === 'number' && Number.isSafeInteger(n) && n >= 0; + const values = (v: unknown): v is Values => + object(v) && + positive(v.version_no) && + typeof v.review_reason === 'string' && + ['reviewed', 'rejected'].includes(String(v.status)); + const evidence = (v: unknown): v is Snapshot => + object(v) && + positive(v.version_no) && + nonnegative(v.quantity) && + [ + 'qualification', + 'status', + 'period_start', + 'period_end', + 'valid_until', + 'source', + 'reference_date', + 'employer', + ].every((k) => typeof v[k] === 'string') && + object(v.references) && + Object.values(v.references).every( + (v) => v === null || typeof v === 'string', + ); + if ( + !object(value) || + value.actor_id !== actorId || + value.kind !== kind || + value.record_id !== row.id || + !nonnegative(value.version) || + !( + value.operation_key === null || + typeof value.operation_key === 'string' + ) || + typeof value.unavailable !== 'boolean' + ) + return false; + if (value.snapshot === null) return true; + const state = value.snapshot; + if ( + !object(state) || + !values(state.values) || + !evidence(state.base) || + state.values.version_no !== state.base.version_no || + !object(state.generation) || + !nonnegative(state.generation.status) || + !nonnegative(state.generation.review_reason) + ) + return false; + return ( + state.pending === null || + (object(state.pending) && + values(state.pending.values) && + object(state.pending.values) && + 'client_key' in state.pending.values && + typeof state.pending.values.client_key === 'string' && + evidence(state.pending.base) && + state.pending.values.version_no === + state.pending.base.version_no && + nonnegative(state.pending.reasonGeneration) && + nonnegative(state.pending.statusGeneration)) + ); + } + async function persistPreparation(attempt?: Attempt): Promise { + if ( + invalid || + preparation === undefined || + saving.current || + (attempt !== undefined && form.processing) + ) + return false; + saving.current = true; + setPersisting(true); + try { + preparationSave.current ??= { + actor_id: actorId, + version: preparationVersion.current, + operation_key: createOperationKey(), + snapshot: privateSnapshot(), + }; + const sent = preparationSave.current; + const value = await profileJson(preparationUrl, sent); + if (!alive.current || preparationSave.current !== sent) + return false; + if (!validPreparation(value)) + throw new Error( + 'A resposta da preparação não pôde ser conferida.', + ); + if (value.unavailable || !value.snapshot) + throw new Error( + 'A intenção guardada não pôde ser recuperada com segurança.', + ); + setSavedPreparation(value); + if (value.operation_key !== sent.operation_key) { + setNotice( + 'Há uma preparação mais recente noutra janela. Foi preservada; recupere e compare antes de continuar.', + ); + return false; + } + preparationVersion.current = value.version; + preparationSave.current = null; + if ( + attempt && + sent.snapshot.pending?.values.client_key !== + attempt.values.client_key + ) { + setNotice( + 'A preparação anterior foi recuperada. Guarde a intenção mais recente antes de enviar a revisão.', + ); + return false; + } + setNotice( + JSON.stringify(sent.snapshot) === + JSON.stringify(privateSnapshot()) + ? 'Preparação privada guardada. Ainda não constitui uma decisão de revisão.' + : 'Preparação guardada; as edições feitas entretanto continuam por guardar.', + ); + return true; + } catch (error) { + if (alive.current) + setNotice( + (error instanceof Error + ? error.message + : 'Não foi possível guardar a preparação.') + + ' A decisão não foi enviada. Repita a mesma gravação para recuperar a resposta incerta.', + ); + return false; + } finally { + saving.current = false; + if (alive.current) setPersisting(false); + } + } + async function restorePreparation() { + if ( + invalid || + saving.current || + form.processing || + preparation === undefined + ) + return; + saving.current = true; + setPersisting(true); + const before = JSON.stringify(privateSnapshot()); + try { + const value = await profileJson( + `${preparationUrl}?actor_id=${actorId}`, + {}, + 'GET', + ); + if (!alive.current) return; + if (!validPreparation(value)) + throw new Error( + 'A preparação guardada não pôde ser conferida.', + ); + setSavedPreparation(value); + if (value.unavailable || !value.snapshot) { + setNotice( + value.unavailable + ? 'A preparação guardada está indisponível e foi preservada.' + : 'Ainda não existe preparação guardada para esta conta e declaração.', + ); + return; + } + if (before !== JSON.stringify(privateSnapshot())) { + setNotice( + 'Conserve as novas edições: a preparação recuperada não as substituiu. Compare antes de recuperar novamente.', + ); + return; + } + if ( + (live.current.review_reason.trim() || pending.current) && + !window.confirm( + 'Substituir a preparação local pela versão privada guardada?', + ) + ) + return; + const state = structuredClone(value.snapshot); + preparationVersion.current = value.version; + preparationSave.current = null; + base.current = state.base; + generation.current = state.generation; + pending.current = state.pending; + live.current = state.values; + form.setData(state.values); + form.clearErrors(); + setUncertain(state.pending !== null); + setConflict(false); + setAccessBlocked(false); + setNotice( + 'Preparação recuperada. Confira a versão e a fonte actuais antes de enviar.', + ); + if (state.pending) + acknowledge(observed.current.row, observed.current.references); + } catch (error) { + if (alive.current) + setNotice( + error instanceof Error + ? error.message + : 'Não foi possível recuperar a preparação.', + ); + } finally { + saving.current = false; + if (alive.current) setPersisting(false); + } + } const invalid = !positive(actorId) || row.review_context?.actor_id !== actorId || @@ -200,6 +449,7 @@ export function useEducationReview({ function submit() { if ( invalid || + saving.current || accessBlocked || refreshing || form.processing || @@ -226,53 +476,63 @@ export function useEducationReview({ const attempt = pending.current; setUncertain(true); setNotice('A aguardar confirmação da revisão.'); - form.transform(() => attempt.values); - form.post(`/management/education/${kind}/${row.id}/review`, { - preserveScroll: true, - onSuccess: (page) => { - if (!alive.current || pending.current !== attempt) return; - const record = read(page.props), - labels = returnedReferences(page.props); - if (!record || !labels || !acknowledge(record, labels)) - setNotice( - 'A resposta não permitiu confirmar a revisão. Actualize a declaração ou recupere o mesmo envio.', - ); - }, - onError: () => { - if (!alive.current || pending.current !== attempt) return; - pending.current = null; - setUncertain(false); - setNotice( - 'Reveja os campos assinalados. A decisão preparada foi conservada.', - ); - }, - onNetworkError: () => { - if (alive.current && pending.current === attempt) - setNotice( - 'Sem confirmação da revisão. Recupere o mesmo envio antes de preparar outra decisão.', - ); - }, - onCancel: () => { - if (alive.current && pending.current === attempt) - setNotice( - 'Envio interrompido sem confirmação. Recupere o mesmo envio.', - ); - }, - onHttpException: (response) => { - if (!alive.current || pending.current !== attempt) return; - if ([401, 403, 419].includes(response.status)) - setAccessBlocked(true); - if (response.status === 422) { + const send = () => { + form.transform(() => attempt.values); + form.post(`/management/education/${kind}/${row.id}/review`, { + preserveScroll: true, + onSuccess: (page) => { + if (!alive.current || pending.current !== attempt) return; + const record = read(page.props), + labels = returnedReferences(page.props); + if (!record || !labels || !acknowledge(record, labels)) + setNotice( + 'A resposta não permitiu confirmar a revisão. Actualize a declaração ou recupere o mesmo envio.', + ); + }, + onError: () => { + if (!alive.current || pending.current !== attempt) return; pending.current = null; setUncertain(false); - } - if ([409, 422].includes(response.status)) setConflict(true); - setNotice( - 'Não foi possível confirmar a revisão. Actualize a declaração e o acesso antes de continuar. A decisão preparada foi conservada.', - ); - }, - }); + setNotice( + 'Reveja os campos assinalados. A decisão preparada foi conservada.', + ); + }, + onNetworkError: () => { + if (alive.current && pending.current === attempt) + setNotice( + 'Sem confirmação da revisão. Recupere o mesmo envio antes de preparar outra decisão.', + ); + }, + onCancel: () => { + if (alive.current && pending.current === attempt) + setNotice( + 'Envio interrompido sem confirmação. Recupere o mesmo envio.', + ); + }, + onHttpException: (response) => { + if (!alive.current || pending.current !== attempt) return; + if ([401, 403, 419].includes(response.status)) + setAccessBlocked(true); + if (response.status === 422) { + pending.current = null; + setUncertain(false); + } + if ([409, 422].includes(response.status)) setConflict(true); + setNotice( + 'Não foi possível confirmar a revisão. Actualize a declaração e o acesso antes de continuar. A decisão preparada foi conservada.', + ); + }, + }); + }; + if (preparation !== undefined) { + setNotice('A conservar a intenção de revisão antes do envio.'); + void persistPreparation(attempt).then((stored) => { + if (stored && alive.current && pending.current === attempt) + send(); + }); + } else send(); } + function refresh() { if (refreshing || form.processing) return; setRefreshing(true); @@ -360,6 +620,11 @@ export function useEducationReview({ }, []); return { form, + persistPreparation: () => persistPreparation(), + restorePreparation, + preparationAvailable: preparation !== undefined, + savedPreparation, + persisting, change, submit, refresh, @@ -378,6 +643,7 @@ export function useEducationReview({ !refreshing && !form.processing, blocked: + persisting || invalid || accessBlocked || refreshing || diff --git a/routes/web.php b/routes/web.php index 0239e7ac..821ce2b1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -294,6 +294,7 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class]) Route::get('{kind}/{id}', [EducationController::class, 'declaration'])->whereIn('kind',['demands','capacities'])->whereUuid('id')->name('declaration'); Route::post('catalog/{kind}', [EducationController::class, 'catalog'])->whereIn('kind', ['skills', 'occupations', 'providers', 'courses'])->name('catalog'); Route::post('{kind}', [EducationController::class, 'store'])->whereIn('kind', ['demands', 'capacities'])->name('store'); + Route::match(['GET', 'POST'], '{kind}/{id}/review-preparation', [EducationController::class, 'reviewPreparation'])->whereIn('kind', ['demands','capacities'])->whereUuid('id'); Route::post('{kind}/{id}/review', [EducationController::class, 'review'])->whereIn('kind', ['demands', 'capacities'])->whereUuid('id')->name('review'); Route::get('demands/{id}/analysis', [EducationController::class, 'analysis'])->whereUuid('id')->name('analysis'); Route::post('demands/{id}/investment', [EducationInvestmentController::class, 'store'])->whereUuid('id')->name('investment'); diff --git a/tests/Feature/EducationReviewPreparationTest.php b/tests/Feature/EducationReviewPreparationTest.php new file mode 100644 index 00000000..e36543c2 --- /dev/null +++ b/tests/Feature/EducationReviewPreparationTest.php @@ -0,0 +1,174 @@ +withoutVite(); + config(['education.enabled' => true]); + $identity = DB::selectOne('select current_database() as name,inet_server_port() as port'); + $this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name); + $this->assertSame(55441, (int) $identity->port); + $this->reviewer = $this->manager(); + } + + private function manager(): User + { + $u = User::factory()->create(); + config(['portal.managers' => [...config('portal.managers', []), $u->email]]); + $this->actingAs($u); + + return $u; + } + + private function record(string $kind): LabourDemand|TrainingCapacity + { + $place = (string) Str::uuid(); + DB::table('geo_locations')->insert(['id' => $place, 'name' => 'SYNTHETIC province', 'code' => $place, 'location_type' => 'province']); + $data = ['occupation_id' => Occupation::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC occupation'])->id, 'skill_id' => Skill::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC skill'])->id, + 'geo_location_id' => $place, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'period_start' => today()->addMonth()->toDateString(), 'period_end' => today()->addMonths(2)->toDateString(), + 'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'submitted_by' => User::factory()->create()->id, 'status' => 'submitted', 'version_no' => 1]; + if ($kind === 'demands') { + return LabourDemand::create([...$data, 'project_id' => Project::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC project', 'status' => 'draft'])->id, 'employer' => 'SYNTHETIC employer']); + } + $provider = TrainingProvider::create(['name' => 'SYNTHETIC provider', 'status' => 'candidate']); + $course = Course::create(['provider_id' => $provider->id, 'code' => (string) Str::uuid(), 'title' => 'SYNTHETIC course', 'status' => 'draft']); + + return TrainingCapacity::create([...$data, 'course_id' => $course->id]); + } + + private function state(LabourDemand|TrainingCapacity $record): array + { + $base = ['version_no' => 1, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'status' => 'submitted', 'period_start' => $record->getRawOriginal('period_start'), 'period_end' => $record->getRawOriginal('period_end'), + 'valid_until' => $record->getRawOriginal('valid_until'), 'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'employer' => '', 'references' => []]; + + return ['values' => ['version_no' => 1, 'status' => 'reviewed', 'review_reason' => ' SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE '], 'generation' => ['status' => 0, 'review_reason' => 1], 'base' => $base, 'pending' => null]; + } + + private function url(string $kind, LabourDemand|TrainingCapacity $r): string + { + return '/management/education/'.$kind.'/'.$r->id.'/review-preparation'; + } + + public function test_private_preparation_is_encrypted_actor_scoped_idempotent_and_preserves_the_declaration(): void + { + foreach (['demands', 'capacities'] as $kind) { + $record = $this->record($kind); + $before = $record->fresh()->getAttributes(); + $url = $this->url($kind, $record); + $body = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($record)]; + $response = $this->postJson($url, $body)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('version', 1); + $this->assertStringContainsString('"references":{}', $response->getContent()); + $saved = $response->json(); + $this->assertSame($body['snapshot'], $saved['snapshot']); + $this->postJson($url, $body)->assertOk()->assertJsonPath('version', 1); + $query = DB::table('audit_events')->where('source_system', 'portal-education-review')->where('entity_id', $record->id)->where('action', 'review_preparation_saved'); + $this->assertSame(1, $query->count()); + $this->assertStringNotContainsString('SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE', $query->value('after_data')); + $this->getJson($url)->assertOk()->assertJsonPath('snapshot.values.review_reason', $body['snapshot']['values']['review_reason']); + $this->assertSame($before, $record->fresh()->getAttributes()); + $this->assertSame(0, DB::table('audit_events')->where('entity_id', $record->id)->where('action', 'review_committed')->count()); + $this->get('/management/education/'.$kind.'/'.$record->id)->assertOk()->assertInertia(fn ($page) => $page->where('preparation.version', 1)->where('history.total', 0)); + $this->manager(); + $this->getJson($url)->assertOk()->assertJsonPath('version', 0)->assertJsonPath('snapshot', null); + $this->actingAs($this->reviewer); + $this->postJson($url, [...$body, 'snapshot' => [...$body['snapshot'], 'values' => [...$body['snapshot']['values'], 'review_reason' => 'DIFFERENT NOTE USING THE SAME KEY']]])->assertConflict(); + } + } + + public function test_private_preparation_optimistic_version_and_late_replay_never_replace_newer_notes(): void + { + $r = $this->record('demands'); + $url = $this->url('demands', $r); + $first = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)]; + $this->postJson($url, $first)->assertOk(); + $second = [...$first, 'version' => 1, 'operation_key' => (string) Str::uuid()]; + $second['snapshot']['values']['review_reason'] = 'SYNTHETIC deliberate newer note from a second window.'; + $this->postJson($url, $second)->assertOk()->assertJsonPath('version', 2); + $this->postJson($url, $first)->assertOk()->assertJsonPath('version', 2)->assertJsonPath('snapshot.values.review_reason', $second['snapshot']['values']['review_reason']); + $this->postJson($url, [...$first, 'operation_key' => (string) Str::uuid()])->assertConflict(); + $this->assertSame(2, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->count()); + $other = $this->manager(); + $this->postJson($url, $first)->assertConflict(); + $this->getJson($url.'?actor_id='.$this->reviewer->id)->assertConflict(); + $this->getJson($url)->assertOk()->assertJsonPath('snapshot', null)->assertJsonPath('actor_id', $other->id); + } + + public function test_uncertain_review_is_recovered_from_its_private_intent_and_same_native_command_once(): void + { + $r = $this->record('demands'); + $state = $this->state($r); + $key = (string) Str::uuid(); + $state['pending'] = ['values' => [...$state['values'], 'client_key' => $key], 'reasonGeneration' => 1, 'statusGeneration' => 0, 'base' => $state['base']]; + $state['values']['review_reason'] = 'SYNTHETIC later edit retained after an uncertain response.'; + $state['generation']['review_reason'] = 2; + $url = $this->url('demands', $r); + $this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $state])->assertOk(); + $native = '/management/education/demands/'.$r->id.'/review'; + $this->post($native, $state['pending']['values'])->assertRedirect(); + $recovered = $this->getJson($url)->assertOk()->json('snapshot'); + $this->assertSame($state, $recovered); + $this->post($native, $recovered['pending']['values'])->assertRedirect(); + $this->assertSame(2, $r->fresh()->version_no); + $this->assertSame(1, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_committed')->count()); + $context = app(EducationReviews::class)->context($this->reviewer, $r->fresh()); + $this->assertSame($key, $context['commands'][0]['key']); + $page = $this->get('/management/education/demands/'.$r->id)->assertOk()->viewData('page')['props']; + $directory = base_path('outputs/education-review-preparation-20261111'); + if (! is_dir($directory)) { + mkdir($directory, 0700, true); + } + file_put_contents($directory.'/recovered-page.json', json_encode(['synthetic' => true, 'transactional_QA' => true, 'props' => array_intersect_key($page, array_flip(['declaration', 'kind', 'actorId', 'today', 'references', 'preparation', 'history', 'backUrl']))], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + + } + + public function test_unreadable_private_preparation_and_unauthorised_access_preserve_previous_bytes(): void + { + $r = $this->record('demands'); + $url = $this->url('demands', $r); + $bytes = json_encode(['education_preparation' => ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => 'SYNTHETIC unreadable original encrypted bytes']], JSON_THROW_ON_ERROR); + DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted', 'after_data' => $bytes]); + $stored = DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data'); + $this->getJson($url)->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null); + $this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)])->assertConflict(); + $this->assertSame($stored, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data')); + $this->actingAs(User::factory()->create()); + $this->getJson($url)->assertForbidden(); + $this->postJson($url, [])->assertForbidden(); + } + + public function test_invalid_decrypted_snapshots_are_unavailable_without_losing_the_encrypted_original(): void + { + foreach (['{', json_encode(['unexpected' => 'SYNTHETIC invalid snapshot'], JSON_THROW_ON_ERROR)] as $plain) { + $r = $this->record('demands'); + $cipher = Crypt::encryptString($plain); + DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted', + 'after_data' => json_encode(['education_preparation' => ['version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => $cipher]], JSON_THROW_ON_ERROR)]); + $this->getJson($this->url('demands', $r))->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null); + $actual = json_decode(DB::table('audit_events')->where('entity_id', $r->id)->value('after_data'), true); + $this->assertSame($cipher, $actual['education_preparation']['snapshot']); + } + } +} diff --git a/tests/frontend/education-review-preparation.test.mjs b/tests/frontend/education-review-preparation.test.mjs new file mode 100644 index 00000000..25d58020 --- /dev/null +++ b/tests/frontend/education-review-preparation.test.mjs @@ -0,0 +1,300 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { harness, props, text } from './helpers/education-review.mjs'; +const initial = { + actor_id: 1, + kind: 'demands', + record_id: props().declaration.id, + version: 0, + operation_key: null, + snapshot: null, + unavailable: false, +}; +const reason = 'Justificação e evidência da revisão'; +const receipt = (request) => ({ + actor_id: request.data.actor_id, + kind: request.url.split('/')[3], + record_id: request.url.split('/')[4], + version: request.data.version + 1, + operation_key: request.data.operation_key, + snapshot: request.data.snapshot, + unavailable: false, +}); +test('private review preparation is recovered after remount and never decides on save or restore', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC deliberate reviewer note for later recovery.', + ); + h.click('Guardar preparação da revisão'); + assert.equal(h.sent.length, 0); + const request = h.preparations[0]; + await h.preparationResponse(receipt(request)); + const reopened = harness(props({ preparation: receipt(request) })); + assert.equal(reopened.field('education-review', reason).props.value, ''); + reopened.click('Recuperar preparação guardada'); + await reopened.preparationResponse(receipt(request)); + assert.equal( + reopened.field('education-review', reason).props.value, + request.data.snapshot.values.review_reason, + ); + assert.equal(reopened.sent.length, 0); +}); +test('review waits for a durable intent; a lost preparation response retries exactly before one native decision', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC proof of durable intent before native decision.', + ); + h.submit('education-review'); + assert.equal(h.sent.length, 0); + const request = h.preparations[0]; + assert.ok(request.data.snapshot.pending.values.client_key); + await h.preparationFailure( + new Error('SYNTHETIC lost persistence response'), + ); + assert.equal(h.sent.length, 0); + h.submit('education-review'); + assert.deepEqual(h.preparations[1].data, request.data); + await h.preparationResponse(receipt(request)); + assert.equal(h.sent.length, 1); + assert.equal( + h.sent[0].data.client_key, + request.data.snapshot.pending.values.client_key, + ); + h.fail(h.sent[0]); + const fresh = harness(props({ preparation: receipt(request) })); + fresh.click('Recuperar preparação guardada'); + await fresh.preparationResponse(receipt(request)); + fresh.submit('education-review'); + const resumed = fresh.preparations.at(-1); + await fresh.preparationResponse(receipt(resumed)); + assert.deepEqual(fresh.sent[0].data, h.sent[0].data); +}); +test('restoring an older declaration requires comparison and preserves deliberate edits during loading', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC stored reasoning against the first version.', + ); + h.click('Guardar preparação da revisão'); + const saved = receipt(h.preparations[0]); + await h.preparationResponse(saved); + const fresh = harness( + props({ + preparation: saved, + declaration: { + ...h.props.declaration, + version_no: 2, + quantity: 40, + }, + }), + ); + fresh.click('Recuperar preparação guardada'); + await fresh.preparationResponse(saved); + assert.match(text(fresh.tree), /A declaração mudou/); + fresh.submit('education-review'); + assert.equal(fresh.sent.length, 0); + const loading = harness(props({ preparation: saved })); + loading.click('Recuperar preparação guardada'); + loading.change( + 'education-review', + reason, + 'SYNTHETIC later edit while recovery response is pending.', + ); + await loading.preparationResponse(saved); + assert.match( + loading.field('education-review', reason).props.value, + /later edit/, + ); + assert.match(text(loading.tree), /não as substituiu/); +}); +test('late private preparation responses cannot populate a different actor', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC private content for the original reviewer only.', + ); + h.click('Guardar preparação da revisão'); + const request = h.preparations[0]; + h.render({ + ...h.props, + actorId: 3, + declaration: { + ...h.props.declaration, + review_context: { actor_id: 3, commands: [] }, + }, + preparation: initial, + }); + await h.preparationResponse(receipt(request)); + assert.equal(h.field('education-review', reason).props.value, ''); + assert.equal(h.sent.length, 0); +}); + +test('a newer saved preparation in another window never causes the older intent to be submitted', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC first window prepares a review.', + ); + h.submit('education-review'); + const request = h.preparations[0]; + await h.preparationResponse({ + ...receipt(request), + version: 4, + operation_key: 'another-window', + }); + assert.equal(h.sent.length, 0); + assert.match(text(h.tree), /noutra janela/); + h.submit('education-review'); + assert.deepEqual( + h.preparations.at(-1).data, + request.data, + 'A newer server version must not be adopted silently', + ); +}); +test('restoring a committed uncertain attempt acknowledges the native receipt and retains edits after sending', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC original submitted reason.', + ); + h.submit('education-review'); + const request = h.preparations[0]; + await h.preparationResponse(receipt(request)); + h.fail(h.sent[0]); + h.change( + 'education-review', + reason, + 'SYNTHETIC later deliberate note after lost native response.', + ); + h.click('Guardar preparação da revisão'); + const saved = receipt(h.preparations.at(-1)); + await h.preparationResponse(saved); + const command = request.data.snapshot.pending.values.client_key; + const fresh = harness( + props({ + preparation: saved, + declaration: { + ...h.props.declaration, + version_no: 2, + status: 'reviewed', + review_context: { + actor_id: 1, + commands: [{ key: command, version: 2 }], + }, + }, + }), + ); + fresh.click('Recuperar preparação guardada'); + await fresh.preparationResponse(saved); + assert.equal(fresh.sent.length, 0); + assert.match( + fresh.field('education-review', reason).props.value, + /later deliberate note/, + ); + assert.match(text(fresh.tree), /Revisão registada/); +}); + +test('a private preparation response for another account or declaration never unlocks a review', async () => { + for (const patch of [ + { actor_id: 2 }, + { record_id: 'another-record' }, + { kind: 'capacities' }, + ]) { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC note belongs to this reviewer and declaration only.', + ); + h.submit('education-review'); + const request = h.preparations[0]; + await h.preparationResponse({ ...receipt(request), ...patch }); + assert.equal(h.sent.length, 0); + assert.match(text(h.tree), /não pôde ser conferida/); + } +}); + +test('an unreadable persistence receipt cannot release the business decision', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC intent must remain recoverable before deciding.', + ); + h.submit('education-review'); + await h.preparationResponse({ + ...receipt(h.preparations[0]), + snapshot: null, + unavailable: true, + }); + assert.equal(h.sent.length, 0); + assert.match(text(h.tree), /não pôde ser recuperada/); +}); + +test('edits made while a native decision is in flight can be saved without a second decision', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC original native decision reason.', + ); + h.submit('education-review'); + const first = h.preparations[0]; + await h.preparationResponse(receipt(first)); + assert.equal(h.sent[0].form.processing, true); + h.change( + 'education-review', + reason, + 'SYNTHETIC later note while native response is pending.', + ); + h.click('Guardar preparação da revisão'); + const during = h.preparations.at(-1); + assert.equal( + during.data.snapshot.pending.values.client_key, + h.sent[0].data.client_key, + ); + assert.match(during.data.snapshot.values.review_reason, /later note/); + await h.preparationResponse(receipt(during)); + assert.equal(h.sent.length, 1); +}); +test('a receipt observed during recovery acknowledges the current declaration instead of a captured old page', async () => { + const h = harness(props({ preparation: initial })); + h.change( + 'education-review', + reason, + 'SYNTHETIC a review whose response was uncertain.', + ); + h.submit('education-review'); + const request = h.preparations[0]; + await h.preparationResponse(receipt(request)); + const fresh = harness(props({ preparation: receipt(request) })); + fresh.click('Recuperar preparação guardada'); + fresh.render({ + ...fresh.props, + declaration: { + ...fresh.props.declaration, + version_no: 2, + status: 'reviewed', + review_context: { + actor_id: 1, + commands: [ + { + key: request.data.snapshot.pending.values.client_key, + version: 2, + }, + ], + }, + }, + }); + await fresh.preparationResponse(receipt(request)); + assert.equal(fresh.sent.length, 0); + assert.match(text(fresh.tree), /Revisão registada/); +}); diff --git a/tests/frontend/helpers/education-review.mjs b/tests/frontend/helpers/education-review.mjs index fc9a1548..7bc5cd08 100644 --- a/tests/frontend/helpers/education-review.mjs +++ b/tests/frontend/helpers/education-review.mjs @@ -61,6 +61,7 @@ export function harness(initial = props()) { const frames = new Map(), sent = [], reloads = [], + preparations = [], before = new Set(), unload = new Set(); let active, @@ -234,6 +235,18 @@ export function harness(initial = props()) { taskStates: { open: 'Aberta', done: 'Conclu?da' }, }, }; + mocks['./profile-json'] = { + profileJson: (url, data = {}, method = 'POST') => + new Promise((resolve, reject) => + preparations.push({ + url, + data: clone(data), + method, + resolve, + reject, + }), + ), + }; const operation = { createOperationKey: () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, '0')}`, @@ -366,6 +379,23 @@ export function harness(initial = props()) { render(); return { sent, + preparations, + async preparationResponse(value, request = preparations.at(-1)) { + const previousWindow = globalThis.window; + globalThis.window = windowMock; + try { + request.resolve(clone(value)); + await new Promise((resolve) => setImmediate(resolve)); + render(); + } finally { + globalThis.window = previousWindow; + } + }, + async preparationFailure(error, request = preparations.at(-1)) { + request.reject(error); + await new Promise((resolve) => setImmediate(resolve)); + render(); + }, reloads, render, get props() { diff --git a/tools/education-review-preparation-proof.mjs b/tools/education-review-preparation-proof.mjs new file mode 100644 index 00000000..ed0cfd1b --- /dev/null +++ b/tools/education-review-preparation-proof.mjs @@ -0,0 +1,17 @@ +import fs from 'node:fs'; +import assert from 'node:assert/strict'; +import { harness,props,text } from '../tests/frontend/helpers/education-review.mjs'; +const out=new URL('../outputs/education-review-preparation-20261111/',import.meta.url); +const source=JSON.parse(fs.readFileSync(new URL('recovered-page.json',out),'utf8')); +assert.equal(source.synthetic,true); +const h=harness(props(source.props)); +assert.equal(h.sent.length,0); +h.click('Recuperar preparação guardada'); +await h.preparationResponse(source.props.preparation); +assert.equal(h.sent.length,0); +assert.match(text(h.tree),/Revisão registada/); +assert.match(h.field('education-review','Justificação e evidência da revisão').props.value,/later edit retained/); +const escape = value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +fs.writeFileSync(new URL('recovered-review.html',out), 'Prova de recuperação QA

Recuperação sintética em QA

Transcrição do componente no harness; não constitui conferência visual no navegador.

' + escape(text(h.tree)) + '
'); +fs.writeFileSync(new URL('verification.json',out),JSON.stringify({synthetic:true,nativeRecoveredPage:true,actualComponent:true,privateActorBound:true,recordId:source.props.declaration.id,kind:source.props.kind,acknowledgedVersion:source.props.declaration.version_no,retainedLaterEdit:true,additionalBusinessPosts:h.sent.length,visualBrowserReview:false,activeDataChanged:false},null,2)); +console.log('Native QA page recovered through actual review component; later edits retained, no duplicate decision.'); diff --git a/tools/education-review-preparation-qa.php b/tools/education-review-preparation-qa.php new file mode 100644 index 00000000..6f733fca --- /dev/null +++ b/tools/education-review-preparation-qa.php @@ -0,0 +1,34 @@ + 'testing', 'APP_KEY' => 'base64:'.base64_encode(str_repeat('Q', 32)), + 'APP_CONFIG_CACHE' => $root.'/outputs/education-review-preparation-20261111/no-config.php', + 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', + 'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'investmoz_test', 'DB_PASSWORD' => '', + 'DB_URL' => '', 'DATABASE_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', + 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'SUITECRM_ENABLED' => 'false'] as $key => $value) { + putenv($key.'='.$value); + $_ENV[$key] = $_SERVER[$key] = $value; +} +$pdo = new PDO('pgsql:host=127.0.0.1;port=55441;dbname=simulator_fiscal_delta_20261021_test', 'investmoz_test', ''); +$identity = $pdo->query('select current_database() as name,inet_server_port() as port')->fetch(PDO::FETCH_ASSOC); +if ($identity['name'] !== 'simulator_fiscal_delta_20261021_test' || (int) $identity['port'] !== 55441) { + throw new RuntimeException('Isolated QA identity required.'); +} +if (($argv[1] ?? '') === 'analyse') { + $argv = ['phpstan', 'analyse', '--no-progress', '--memory-limit=512M', '--configuration='.$root.'/outputs/funding-directory-20261110/phpstan.neon', 'app/Services/EducationReviews.php', 'app/Http/Controllers/EducationController.php']; + $_SERVER['argv'] = $argv; + $_SERVER['argc'] = count($argv); + require $root.'/vendor/autoload.php'; + require $root.'/vendor/phpstan/phpstan/phpstan'; + exit; +} +$argv = ['phpunit', '-c', 'phpunit.investmoz.xml', '--do-not-cache-result', 'tests/Feature/EducationReviewPreparationTest.php']; +$argc = count($argv); +$_SERVER['argv'] = $argv; +$_SERVER['argc'] = $argc; +require $root.'/vendor/autoload.php'; +require $root.'/vendor/phpunit/phpunit/phpunit';