Fix compressed draft saves provenance and Beira documentary continuity
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-23 15:41:37 +02:00
parent c3f7c3ad10
commit 6b544548d3
16 changed files with 145 additions and 15 deletions
@@ -89,7 +89,7 @@ class InvestmentDiscoveryController extends Controller
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) {
$linked = false;
$linked = in_array($record->id, app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
$linked = true;
@@ -105,6 +105,7 @@ class InvestmentPortfolioController extends Controller
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false');
$query->orWhereIn('id', app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
if ($record->$foreign) {
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id'));
@@ -13,6 +13,17 @@ use Ramsey\Uuid\Uuid;
final class CorridorAgribusinessOpportunities
{
public const SOURCE_HASH = '1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055';
/** Documentary cross-reference only; keep the two source designations and master records intact. */
public function portfolioOpportunityIds(\App\Models\InvestmentDossier $dossier): array
{
if (!$dossier->corridor_id || data_get($dossier->content, 'code') !== 'COR-02') return [];
// COR-02 is Beira e Centro in investment-portfolio.json. The seven entries are explicitly
// grouped under Corredor da Beira in the accepted source, not selected by province/name search.
$keys = collect($this->entries())->where('corridor', 'Corredor da Beira')->pluck('key');
return Opportunity::published()->whereIn('management_creation_key', $keys)
->where('workflow_context->corridor_agribusiness->source_sha256', self::SOURCE_HASH)->pluck('id')->all();
}
public const LIMITATIONS = 'Promotor, implantação exacta, área, montante, financiamento, viabilidade e licenças não indicados nesta fonte. O corredor é um enquadramento documental; não identifica uma parcela nem confirma disponibilidade.';
public function entries(): array
+4
View File
@@ -292,6 +292,10 @@ class InvestmentCatalogue
}
}
});
if (!empty($selected['corridor_id'])) {
$dossier = InvestmentPortfolioController::visible()->where('corridor_id', $selected['corridor_id'])->first();
if ($dossier) $ids['corridor_id'] = array_values(array_unique([...($ids['corridor_id'] ?? []), ...app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossier)]));
}
return $ids;
}
@@ -15,6 +15,7 @@ final class InvestmentOpportunityChoices
foreach (array_unique($ids) as $id) {
$record = InvestmentPortfolioController::visible()->addSelect('version_no')->findOrFail($id);
$kind = null;
if (in_array($opportunity, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record), true)) $kind = 'corridor';
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($record->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $opportunity)->where($foreign, $record->$foreign)->exists()) $kind = rtrim($relation, 's');
}
+1
View File
@@ -632,6 +632,7 @@ final class InvestorContext
'requirements_assessment' => $ctx['requirements_assessment'] ?? null,
'path' => $ctx['path_applicability'] ?? null,
'land_preparation' => $ctx['land_preparation'] ?? null,
'field_provenance' => array_map(fn($origin) => Arr::only($origin, ['kind', 'title', 'version', 'context_revision']), $ctx['provenance'] ?? []),
'preparation' => isset($ctx['preparation_assessment']) ? Arr::except($ctx['preparation_assessment'], ['questions']) : null,
'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']),
'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.',
@@ -1,5 +1,17 @@
# Rastreabilidade, utilização e formação
## Correcções da verificação de homologação — candidata QA, 23/10/2026
Referência: `c3f7c3ad`; pedido expresso da Tempo nas três prioridades. As 34 oportunidades já públicas e as provas aceites mantêm-se. Esta candidata não altera a homologação, que permanece em `7c1f5321`; sem migrations, novos papéis, publicação, importação ou alteração de dados activos.
1. **Gravação/idioma — causa de transporte reproduzida.** O Nginx do destino comprime JSON e enfraquece o ETag da representação; confirmado em leitura pública do questionário (o controlador produz uma versão forte e a resposta chega `W/"questionnaire-…"`, com gzip). O cliente do rascunho devolvia literalmente o cabeçalho ao servidor, que compara a versão forte exacta; os logs do dossier de teste mostram PUT 200 seguido de 412 e repetição após recuperação. Em QA isolado, um router de prova aplica gzip e enfraquece os ETags dos rascunhos para reproduzir esse transporte. A correcção normaliza apenas o formato conhecido `W/"sfd-UUID-vN"` antes de o usar; não aceita outra versão, não muda o controlo do servidor nem força retry/overwrite. No navegador QA, mudar o idioma para English, guardar e recarregar recuperou English com confirmação de gravação, título deliberado e escolhas preservados, sem falso conflito. A leitura pública comprova o transporte no destino; a correcção do rascunho ainda aguarda instalação/conferência desse delta na homologação. Sem consulta de credenciais ou bypass de sessão/RLS.
2. **Proveniência do relatório.** A origem existente por campo estava no dossier, mas não viajava no snapshot usado pelo relatório; as linhas tinham atribuições genéricas fixas ao utilizador. O snapshot passa a incluir apenas tipo/título/versão/revisão da proveniência, sem IDs de actores. O modelo comum de relatório/CSV usa também as assinaturas de herança já existentes: mostra origem na oportunidade/catálogo/dados do utilizador no dossier; uma alteração posterior no simulador é identificada com a origem anterior. Não muda valores nem reescreve relatórios emitidos; metadados ausentes de versões antigas não são inventados. Conferido no relatório QA: resumo herdado da oportunidade, título alterado posteriormente e referência da oportunidade/localização/associação conservadas. Captura `outputs/preservation-inventory-20261023/corrected-provenance-report-QA.png`.
3. **Beira → oportunidades.** A consulta da carteira só procurava pivots; o cadastro das 34 conserva a pertença documental no `workflow_context`, deliberadamente sem inferir relações com mestres. As fontes aceites distinguem COR-02 «Corredor da Beira e Centro» (`investment-portfolio.json`) e o grupo «Corredor da Beira» (`corridor-investment-opportunities.json`): sete entradas, não selecção por província. Esta correcção implementa uma referência documental delimitada entre essa ficha e esse grupo, sem renomear/fundir corredores, acrescentar pivots ou inferir implantação. A consulta exige a chave original e o hash da fonte aceite; outras oportunidades/corredores não entram por semelhança. A mesma resolução serve a ficha, a pesquisa por corredor, a validação da origem no detalhe e a associação escolhida no dossier. As sete fichas são Milho, trigo e arroz; Soja; Hortícolas; Sésamo; Açúcar; Florestas; Carne bovina — todas no Corredor da Beira. Não se estende esta correspondência aos outros corredores, clusters ou âncoras por inferência.
Validação delimitada: 26 testes frontend afectados passaram; novo teste de proveniência/ETag repetido após o ajuste de designações, dois aprovados. Três testes transaccionais de continuidade/Beira, 91 asserções, na base QA `simulator_fiscal_delta_20261021_test:55441`, incluindo sete resultados, pesquisa, detalhe/origem e recusa de outra oportunidade. Um teste adicional, cinco asserções, confirma que o servidor continua a recusar ETag fraco e versão antiga e aceita apenas a versão forte actual. Types/build aprovados. A conferência visual final da navegação da carteira QA foi interrompida pela automação do navegador; não se declara uma prova visual nova dessa ficha, coberta aqui pela resposta Inertia/API e validação de continuidade. Mantêm-se as provas anteriores. Matriz existente: evidências acrescidas em AUD-SIM-INT03/06/07, sem novo backlog. Aplicação transversal permanece parcial.
## Verificação do produto na homologação — 23/10/2026
Origem: pedido da Tempo de avaliar carteira → escolha → dossier → simulador → relatório, registando apenas diferenças e reutilizando as provas aceites. Revisão servida confirmada: `7c1f5321`. Sem alteração de código, permissões, originais ou publicações nesta passagem. Referências da matriz existente: AUD-SIM-INT03 (recuperação), AUD-SIM-INT06 (relatórios) e AUD-SIM-INT07 (gravação/erros).
File diff suppressed because one or more lines are too long
+9 -8
View File
@@ -1,3 +1,4 @@
import { simulationDraftEtag } from '@/lib/simulation-draft-etag';
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import { SimulationMonthlyIva } from './simulation-monthly-iva';
import { inheritSimulatorFields, reusedInvestmentValue } from '@/lib/context-inheritance';
@@ -1248,7 +1249,7 @@ export function SimulationForm() {
if (payload.data) {
if (cancelled) return;
draftIdRef.current = payload.data.id;
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
setDraftVersionNo(payload.data.version_no);
setDraftExpiresAt(payload.data.expires_at ?? null);
if (canSavePersonalData)
@@ -1370,7 +1371,7 @@ export function SimulationForm() {
if (currentDraft) {
draftIdRef.current = currentDraft.id;
draftEtagRef.current =
currentResponse.headers.get('ETag');
simulationDraftEtag(currentResponse.headers.get('ETag'));
sessionStorage.setItem(draftIdKey, currentDraft.id);
}
}
@@ -1432,7 +1433,7 @@ export function SimulationForm() {
);
draftIdRef.current = payload.data.id;
setServerFieldErrors({});
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
setDraftVersionNo(payload.data.version_no);
lastSavedFingerprintRef.current = fingerprint;
sessionStorage.setItem(draftIdKey, payload.data.id);
@@ -1593,7 +1594,7 @@ export function SimulationForm() {
)
throw new Error('Não foi possível abrir este dossier privado.');
draftIdRef.current = payload.data.id;
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
setDraftVersionNo(payload.data.version_no);
setDraftExpiresAt(null);
setSelectedAccountDraftId(payload.data.id);
@@ -1656,7 +1657,7 @@ export function SimulationForm() {
);
const record = (payload as { data: ApiSimulationDraftV1 }).data;
draftIdRef.current = record.id;
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
setDraftVersionNo(record.version_no);
setDraftExpiresAt(null);
setSelectedAccountDraftId(record.id);
@@ -1756,7 +1757,7 @@ export function SimulationForm() {
(payload as { expires_at: string }).expires_at,
);
draftEtagRef.current =
response.headers.get('ETag') ?? draftEtagRef.current;
simulationDraftEtag(response.headers.get('ETag')) ?? draftEtagRef.current;
}
setContinueLaterSaved(true);
setSaveStatus(
@@ -2375,7 +2376,7 @@ export function SimulationForm() {
);
const draftId = draftIdValue;
draftIdRef.current = draftId;
draftEtagRef.current = currentResponse.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(currentResponse.headers.get('ETag'));
if (!draftEtagRef.current)
throw new Error(
'O servidor não devolveu a versão actual do rascunho. Recarregue e tente novamente.',
@@ -2409,7 +2410,7 @@ export function SimulationForm() {
throw new Error(
'O servidor devolveu uma confirmação de anexação inválida.',
);
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
const sources = payload.data?.simulation_sources;
if (Array.isArray(sources)) setSimulationSources(sources);
setDraft((currentDraft) => ({
+20
View File
@@ -72,6 +72,26 @@ export function reusedInvestmentValue(scope: string, key: string, values: Record
} catch { return null; }
}
/** Report the lineage without changing a stored answer or disclosing the tracking hash. */
export function investmentFieldOrigin(values: Record<string, unknown>, key: string, fallback: string): string {
try {
const reuse = JSON.parse(String(values.context_reuse ?? ''));
if (reuse.version !== 1 || !/^dossier:.+:simulator$/.test(reuse.scope) || !reuse.inherited?.[key]) return fallback;
const snapshot = JSON.parse(String(values.context_applicability_snapshot ?? ''));
const origin = snapshot.field_provenance?.[key];
const source = origin?.kind === 'opportunity' ? `oportunidade «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'process_declaration' || origin?.kind === 'dossier' ? 'dados introduzidos pelo utilizador no dossier'
: origin?.kind === 'sector' ? `catálogo sectorial «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'territory' ? `catálogo territorial «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'linked_simulation' ? 'simulador ligado ao mesmo dossier'
: 'dossier de investimento';
const version = origin?.version ? `, versão ${origin.version}` : '';
return reuse.inherited[key] === signature(values[key])
? `Herdado de ${source}${version}`
: `Alterado posteriormente pelo utilizador; origem anterior: ${source}${version}`;
} catch { return fallback; }
}
export function inheritSimulatorFields<T extends Record<string,unknown>>(draft:T): T {
const source:Record<string,unknown>={};
const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null;
+2 -2
View File
@@ -229,8 +229,8 @@ function makeSections(
{
field: 'Projecto',
value: absent(text(draft, 'project_title')),
origin: 'Informação introduzida no dossier',
classification: 'Declarada pelo utilizador',
origin: rows.find(row => row[1] === 'Título')?.[3] ?? 'Informação introduzida no dossier',
classification: 'Origem indicada no dossier',
},
{
field: 'Data de geração',
+6 -1
View File
@@ -1,4 +1,5 @@
import { displayValue } from './display-value.ts';
import { investmentFieldOrigin } from './context-inheritance.ts';
import { investmentLandPreparation } from './investment-land-preparation.ts';
import { monthlyIvaInput, monthlyIvaKey, readMonthlyIvaRecord, readMonthlyIvaSettings } from './monthly-iva.ts';
import { modeloAPurchaseReferences } from './iva-modelo-a.ts';
@@ -705,7 +706,11 @@ export function buildProjectDossierRows(
if (land.need === 'yes') rows.push(['Localização', 'Situação do terreno', String(draft.land_status_label ?? ({ owned: 'Próprio', duat: 'DUAT', leased: 'Arrendado', requested: 'Pedido em curso', unknown: 'Por determinar' } as Record<string, string>)[String(draft.land_status)] ?? 'Informação em falta'), 'Informação declarada; não comprova título ou disponibilidade']);
else rows.push(['Localização', 'Preparação do terreno', land.need === 'no' ? 'Situação do terreno não aplicável à preparação actual; valores anteriores conservados.' : 'Definir se precisa de terra; pode continuar a preparar o investimento.', 'Sem inferência de dispensa legal']);
}
return rows;
const lineageFields: Record<string, string> = {'Título':'project_title','Resumo':'project_summary','Sector':'sector_id','Província':'province_id','Distrito':'district_id','Posto administrativo':'admin_post_id','Localidade':'locality_id'};
return rows.map(row => {
const key = lineageFields[row[1]];
return key && row[2] ? [row[0], row[1], row[2], investmentFieldOrigin(draft, key, row[3])] : row;
});
}
export function exportProjectDossierCsv(
@@ -0,0 +1,5 @@
/** Compression can mark the representation weak; the draft version token itself is unchanged. */
export function simulationDraftEtag(header: string | null): string | null {
if (header && /^W\/"sfd-[0-9a-f-]{36}-v[1-9][0-9]*"$/i.test(header)) return header.slice(2);
return header;
}
@@ -17,6 +17,41 @@ use Tests\TestCase;
final class InvestmentPortfolioContinuityTest extends TestCase
{
use DatabaseTransactions;
public function test_draft_version_guard_still_rejects_a_real_stale_version(): void
{
$draft = new \App\Models\SimulationFormDraft;
$draft->forceFill(['id'=>(string)Str::uuid(),'version_no'=>2]);
$service=app(\App\Services\SimulationFormDrafts::class);
$guard=new \ReflectionMethod($service,'requireMatch');
$tag=$service->etag($draft);
foreach (['W/'.$tag, str_replace('-v2','-v1',$tag)] as $invalid) {
$request=\Illuminate\Http\Request::create('/','PUT'); $request->headers->set('If-Match',$invalid);
try { $guard->invoke($service,$request,$draft); $this->fail('A mismatching version must be rejected'); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { $this->assertSame(412,$error->getStatusCode()); }
}
$request=\Illuminate\Http\Request::create('/','PUT'); $request->headers->set('If-Match',$tag);
$this->assertNull($guard->invoke($service,$request,$draft));
}
public function test_beira_documentary_membership_opens_the_same_published_opportunities_without_inferring_by_province(): void
{
$portfolio = InvestmentPortfolioController::visible()->where('content->code', 'COR-02')->firstOrFail();
$service = app(CorridorAgribusinessOpportunities::class);
$ids = $service->portfolioOpportunityIds($portfolio);
$this->assertCount(7, $ids);
$this->get('/carteira/'.$portfolio->id)->assertOk()->assertInertia(fn(Assert $page) => $page->has('opportunities', 7));
$catalogueIds=app(\App\Services\InvestmentCatalogue::class)->query(['corridor_id'=>$portfolio->corridor_id])->pluck('opportunities.id')->all();
$this->assertSame([], array_values(array_diff($ids,$catalogueIds)));
$opportunity=Opportunity::findOrFail($ids[0]);
$this->get('/oportunidades/'.$opportunity->slug.'?from_dossier='.$portfolio->id)->assertOk();
$origins=app(InvestmentOpportunityChoices::class)->portfolioOrigins([$portfolio->id],$opportunity->id);
$this->assertSame($portfolio->id,$origins[0]['id']);
$this->assertSame('corridor',$origins[0]['kind']);
$other=Opportunity::published()->whereNotIn('id',$ids)->firstOrFail();
$this->get('/oportunidades/'.$other->slug.'?from_dossier='.$portfolio->id)->assertNotFound();
$this->assertSame([], $service->portfolioOpportunityIds(InvestmentPortfolioController::visible()->whereNotNull('project_id')->firstOrFail()));
}
private User $actor;
protected function setUp(): void
{
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts';
import { buildProjectDossierRows, exportProjectDossierCsv } from '../../resources/js/lib/project-dossier.ts';
import { createProjectDossierReport, createProjectDossierPrintHtml } from '../../resources/js/lib/project-dossier-report.ts';
test('reports distinguish opportunity inheritance, dossier declarations and later edits without rewriting the snapshot', async()=>{
const source={project_title:'Título do promotor',project_summary:'Resumo da oportunidade',sector_id:'1',sector_id_label:'Agronegócio',context_applicability_snapshot:JSON.stringify({version:1,field_provenance:{project_summary:{kind:'opportunity',title:'Florestas — Corredor da Beira',version:2},sector_id:{kind:'opportunity',title:'Florestas — Corredor da Beira',version:2},project_title:{kind:'process_declaration',context_revision:1}}})};
const inherited=inheritCompatibleFields('dossier:A:simulator',{},source,null);
const draft={...inherited.patch,context_reuse:inherited.metadata};
let rows=buildProjectDossierRows(draft);
assert.match(rows.find(r=>r[1]==='Resumo')[3],/Herdado de oportunidade «Florestas/);
assert.match(rows.find(r=>r[1]==='Título')[3],/dados introduzidos pelo utilizador no dossier/);
const edited={...draft,project_summary:'Resumo alterado no simulador'}; const before=JSON.stringify(edited);
rows=buildProjectDossierRows(edited);
assert.match(rows.find(r=>r[1]==='Resumo')[3],/Alterado posteriormente pelo utilizador; origem anterior: oportunidade/);
assert.match(exportProjectDossierCsv(edited),/origem anterior: oportunidade/);
const report=createProjectDossierReport(edited,{selectedSections:new Set(['summary','project'])});
assert.match(await createProjectDossierPrintHtml(report,[],false),/Resumo alterado no simulador/);
assert.equal(JSON.stringify(edited),before);
assert.equal(buildProjectDossierRows({project_summary:'Legado'}).find(r=>r[1]==='Resumo')[3],'Declarado pelo utilizador');
});
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { simulationDraftEtag } from '../../resources/js/lib/simulation-draft-etag.ts';
test('compressed draft reads use the exact version token while genuinely stale versions stay different', () => {
const tag='"sfd-6c07a968-7ff9-42fa-b252-072bb1acf221-v2"';
assert.equal(simulationDraftEtag(`W/${tag}`),tag);
assert.equal(simulationDraftEtag(tag),tag);
assert.notEqual(simulationDraftEtag(`W/${tag}`),tag.replace('-v2','-v3'));
assert.equal(simulationDraftEtag('W/"another-resource"'),'W/"another-resource"');
assert.equal(simulationDraftEtag(null),null);
});