Classify sourced territorial observations without inferring legacy values
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-13 22:44:42 +02:00
parent 7e7bd4fde3
commit 42b9324acb
8 changed files with 238 additions and 11 deletions
+1
View File
@@ -58,6 +58,7 @@ final class InvestmentMapIndicators
'zone_id' => $zone->id, 'zone_name' => $zone->name, 'zone_version' => (int) $zone->version_no, 'url' => '/zonas/'.$zone->id,
'theme' => $row['indicator'] === 'employment' ? 'workforce' : 'economic_potential',
'value' => $value, 'knowledge' => $knowledge, 'missing_metadata' => $missing,
'measure_kind' => in_array($row['measure_kind'] ?? null, ['observed', 'planned', 'projected'], true) ? $row['measure_kind'] : 'unknown',
];
}
}
@@ -18,7 +18,7 @@ class InvestmentZoneIntegralDetails
'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'],
'plots' => ['code', 'phase_id', 'area', 'area_unit', 'status', 'geometry_id'],
'occupants' => ['organization_id', 'documentary_name', 'phase_id', 'plot_id', 'status', 'valid_from', 'valid_until'],
'observations' => ['indicator', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'observations' => ['indicator', 'measure_kind', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'chains' => ['kind', 'organization_id', 'documentary_name', 'zone_link_id', 'phase_id', 'plot_id'],
];
@@ -59,6 +59,7 @@ class InvestmentZoneIntegralDetails
$rules['titles.*.kind'] = 'nullable|in:creation,land_use,licence,special_regime,other';
$rules['chains.*.kind'] = 'nullable|in:anchor,supplier,unmet_need,other';
$rules['observations.*.indicator'] = 'required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other';
$rules['observations.*.measure_kind'] = 'nullable|in:unknown,observed,planned,projected';
$data = Validator::make($input, $rules)->validate();
$result = $before;
foreach (self::GROUPS as $group) {
@@ -148,7 +149,7 @@ class InvestmentZoneIntegralDetails
foreach ($result['observations'] as $item) {
if (! isset($item['period_start'],$item['period_end'])) {
continue;
}$key = implode('|', [$item['indicator'], $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
}$key = implode('|', [$item['indicator'], $item['measure_kind'] ?? 'unknown', $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
abort_if(in_array($key, $keys, true), 422, 'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.');
$keys[] = $key;
}
@@ -9829,3 +9829,12 @@ Backup/restauro fresco de 193 tabelas/410 ficheiros, com contagens e hashes conf
Reutilizadas as provas aceites: três testes novos e a conferência visual sintética do componente; o CI obrigatório do executor correu normalmente, sem nova ronda manual dos percursos aceites. HTTPS do mapa, instituições, demografia e entrada privada conferido. Os originais e a pesquisa, suporte e relatórios de 8854b292 foram preservados. Não foram criadas observações sintéticas na homologação para fazer aparecer a comparação. Recibo: `outputs/map-comparison-20261113/homologation-installed.json`; pacote QA selado inalterado.
Estado do incremento: instalado. ACT-02/V2-07/V2-10 permanecem parciais quanto à cobertura factual, séries estatisticamente comparáveis, oferta laboral, natureza estruturada das grandezas e metodologia económica. Esta entrega fecha a ferramenta de confronto das observações recebidas, não esses requisitos amplos.
## 13/11/2026 — ACT-02: natureza estruturada das observações, QA
Continuidade do confronto instalado em 1263b4f6. O editor existente permite declarar segundo a fonte se o valor é observado/efectivo, planeado/meta, projectado/estimado ou por confirmar. A classificação segue o envelope privado, a recuperação e o fluxo editorial existentes; preparar uma alteração não modifica a versão pública. Omissão por clientes anteriores preserva uma escolha deliberada. Sem indicação anterior, a consulta apresenta por confirmar; não infere natureza pela descrição nem reescreve registos. Fonte, valor textual exacto, zero, identidade, versão, confidencialidade e histórico mantidos. Não há migrações, novas permissões, publicação automática ou importação.
A consulta e a comparação mostram a natureza separada da situação documental; naturezas diferentes ou desconhecidas exigem conferência. Metadados alinhados não provam comparabilidade estatística. QA: três testes backend/21 asserções, cinco testes frontend afectados, análise PHP, Pint, tipos, lint e compilação aprovados. Edge: componentes reais compilados com dados sintéticos; selector e edição preservados, observado/projectado distintos, legado por confirmar, zero/precisão mantidos. Provas em outputs/observation-nature-20261113/. Reutilizadas as provas aceites dos restantes percursos.
Estado: implementado em QA, instalação pendente de autorização concreta; homologação mantém 1263b4f6. Encerra a capacidade estruturada de declarar a natureza, não a classificação factual de todo o acervo. ACT-02/V2-07/V2-10 continuam parciais: cobertura factual nacional/regional, séries estatisticamente comparáveis, oferta laboral, conferência documental dos valores antigos e metodologia de potencial económico. Relatórios, pesquisa, suporte e entregas aceites preservados.
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@ export type QuantitativeMapContext = {
source_reference?: string | null;
source_date?: string | null;
knowledge: string;
measure_kind?: 'unknown' | 'observed' | 'planned' | 'projected';
missing_metadata: string[];
}[];
zones_consulted?: number;
@@ -47,6 +48,17 @@ const metadataLabels: Record<string, string> = {
source_date: 'data da fonte',
};
const text = (value?: string | null) => value?.trim() || 'Não indicado';
const measureKinds: Record<string, string> = {
unknown: 'Por confirmar',
observed: 'Observado / efectivo',
planned: 'Planeado / meta',
projected: 'Projectado / estimado',
};
const measureKind = (row: QuantitativeMapContext['records'][number]) =>
row.measure_kind &&
['observed', 'planned', 'projected'].includes(row.measure_kind)
? row.measure_kind
: 'unknown';
const completeDate = (value?: string | null) =>
Boolean(
value &&
@@ -74,6 +86,10 @@ export function compareMapObservations(
['Primeira', first],
['Segunda', second],
] as const) {
if (measureKind(row) === 'unknown')
issues.push(
`${label} observação: natureza do valor por confirmar na fonte.`,
);
if (row.knowledge !== 'documented')
issues.push(
`${label} observação: informação desconhecida ou em divergência.`,
@@ -107,6 +123,10 @@ export function compareMapObservations(
`${label} observação: data da fonte incompleta ou inválida.`,
);
}
if (measureKind(first) !== measureKind(second))
issues.push(
'Natureza do valor diferente: observado, planeado e projectado não são equivalentes.',
);
for (const [field, label] of Object.entries({
indicator: 'Indicador',
unit: 'Unidade',
@@ -144,6 +164,7 @@ export function MapObservationComparison({
['Território', (row) => row.zone_name],
['Observação', (row) => text(row.label)],
['Descrição e natureza da grandeza', (row) => text(row.description)],
['Natureza do valor', (row) => measureKinds[measureKind(row)]],
['Indicador', (row) => labels[row.indicator] ?? row.indicator],
[
'Valor original',
@@ -188,7 +209,7 @@ export function MapObservationComparison({
Períodos distintos ou sobrepostos exigem conferência da duração
e da base na fonte. Esta apresentação não calcula evolução,
soma, conversão, pontuação ou estimativa regional. A natureza
prevista ou efectiva depende da descrição da fonte.
declarada deve ser conferida com a descrição e a fonte.
</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
@@ -346,9 +367,9 @@ export default function InvestmentMapIndicators({
contexto territorial, independentes das evidências das
oportunidades. Emprego não mede trabalhadores disponíveis;
investimento, produção e exportações não constituem um índice de
potencial ou garantia de retorno. A natureza prevista ou
efectiva de cada valor depende da descrição da fonte, não é
determinada automaticamente.
potencial ou garantia de retorno. A natureza do valor é
declarada segundo a fonte; sem essa indicação permanece por
confirmar.
</p>
{!context ? (
<p role="status">A carregar indicadores quantitativos…</p>
@@ -543,6 +564,14 @@ export default function InvestmentMapIndicators({
'Por determinar'}{' '}
· {text(row.unit)}
</p>
<p>
Natureza:{' '}
{
measureKinds[
measureKind(row)
]
}
</p>
<p>
{row.knowledge ===
'documented'
@@ -60,7 +60,8 @@ const labels: Record<string, string> = {
geometry_id: 'Geometria já associada ao parque',
zone_link_id: 'Relação económica já associada ao parque',
indicator: 'Indicador',
value: 'Valor observado',
measure_kind: 'Natureza do valor segundo a fonte',
value: 'Valor indicado na fonte',
unit: 'Unidade de medida',
period_start: 'Início do período',
period_end: 'Fim do período',
@@ -86,6 +87,7 @@ const extras: Record<Group, string[]> = {
],
observations: [
'indicator',
'measure_kind',
'value',
'unit',
'period_start',
@@ -105,6 +107,12 @@ const extras: Record<Group, string[]> = {
],
};
const choices: Record<string, Record<string, string>> = {
measure_kind: {
unknown: 'Por confirmar',
observed: 'Observado / efectivo',
planned: 'Planeado / meta',
projected: 'Projectado / estimado',
},
knowledge: {
unknown: 'Não informado',
documented: 'Documentado pela fonte',
@@ -248,7 +256,11 @@ export default function ParkIntegralEditor({
})
}
>
<option value="">Não informado / sem associação</option>
<option value="">
{key === 'measure_kind'
? 'Por confirmar na fonte'
: 'Não informado / sem associação'}
</option>
{Object.entries(options).map(([k, v]) => (
<option key={k} value={k}>
{v}
@@ -541,7 +553,9 @@ export function ParkIntegralPublic({ value }: { value?: ParkIntegral }) {
)
: item[k] == null ||
item[k] === ''
? 'Não informado'
? k === 'measure_kind'
? 'Por confirmar'
: 'Não informado'
: k === 'expansion'
? item[k]
? 'Sim, segundo a fonte'
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\InvestmentZone;
use App\Models\User;
use App\Services\InvestmentMapIndicators;
use App\Services\InvestmentZoneIntegralDetails;
use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZoneOperationalProfile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class InvestmentObservationNatureTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
Http::preventStrayRequests();
}
private function observation(?string $kind): array
{
$item = ['id' => (string) Str::uuid(), 'label' => 'SYNTHETIC nature QA', 'description' => 'Synthetic source only.',
'indicator' => 'employment', 'value' => '0', 'unit' => 'pessoas', 'period_start' => '2025-01-01', 'period_end' => '2025-12-31',
'scope' => 'Synthetic zone', 'population' => 'Synthetic establishments', 'methodology' => 'Synthetic register',
'source_reference' => 'SYNTHETIC source; not real statistics', 'source_date' => '2026-01-15', 'knowledge' => 'documented', 'disclosure' => true];
return $kind === null ? $item : [...$item, 'measure_kind' => $kind];
}
private function zone(): InvestmentZone
{
$actor = User::factory()->create(['email' => 'measure-'.Str::uuid().'@example.test']);
return InvestmentZone::create(['code' => 'MEASURE-QA-'.Str::uuid(), 'category' => 'industrial_park', 'name' => 'SYNTHETIC nature QA',
'editor_id' => $actor->id, 'publication_status' => 'draft', 'validation_status' => 'pending']);
}
public function test_explicit_natures_persist_recover_and_project_without_inference_or_private_disclosure(): void
{
$zone = $this->zone();
$service = app(InvestmentZoneIntegralDetails::class);
$ops = app(InvestmentZoneOperationalDetails::class);
$rows = array_map(fn ($kind) => $this->observation($kind), ['observed', 'planned', 'projected', null]);
$hidden = [...$this->observation('observed'), 'disclosure' => false, 'source_reference' => 'PRIVATE-NATURE-SOURCE'];
$merged = $service->merge($zone, [], ['observations' => [...$rows, $hidden]]);
$zone->forceFill(['operational_details' => $ops->pack(['integral' => $merged])])->save();
$saved = $zone->fresh();
$before = $saved->getRawOriginal('operational_details');
$recovered = $ops->recover($saved)['integral']['observations'];
$this->assertSame(['observed', 'planned', 'projected'], array_column(array_slice($recovered, 0, 3), 'measure_kind'));
$this->assertArrayNotHasKey('measure_kind', $recovered[3]);
$this->assertSame('0', $recovered[0]['value']);
$view = app(InvestmentMapIndicators::class)->forZones(collect([$saved]));
$this->assertSame(['observed', 'planned', 'projected', 'unknown'], array_column($view['records'], 'measure_kind'));
$this->assertSame(['0', '0', '0', '0'], array_column($view['records'], 'value'));
$this->assertStringNotContainsString('PRIVATE-NATURE-SOURCE', json_encode($view, JSON_THROW_ON_ERROR));
$this->assertSame($before, $zone->fresh()->getRawOriginal('operational_details'));
$this->assertSame(1, $zone->fresh()->version_no);
}
public function test_invalid_nature_is_rejected_and_an_omitted_nature_does_not_erase_an_explicit_choice(): void
{
$zone = $this->zone();
$service = app(InvestmentZoneIntegralDetails::class);
$row = $this->observation('observed');
$before = $service->merge($zone, [], ['observations' => [$row]]);
$withoutKind = $row;
unset($withoutKind['measure_kind']);
$recovered = $service->merge($zone, $before, ['observations' => [$withoutKind]]);
$this->assertSame('observed', $recovered['observations'][0]['measure_kind']);
$changed = $service->merge($zone, $before, ['observations' => [[...$row, 'measure_kind' => 'planned']]]);
$this->assertSame('planned', $changed['observations'][0]['measure_kind']);
$this->assertSame('observed', $before['observations'][0]['measure_kind']);
try {
$service->merge($zone, $before, ['observations' => [[...$row, 'measure_kind' => 'inferred_from_description']]]);
$this->fail('Invalid nature accepted');
} catch (ValidationException $error) {
$this->assertArrayHasKey('observations.0.measure_kind', $error->errors());
}
}
public function test_nature_change_stays_in_private_preparation_with_history_until_existing_editorial_review(): void
{
$zone = $this->zone();
$ops = app(InvestmentZoneOperationalDetails::class);
$row = $this->observation('observed');
$fields = app(InvestmentZoneIntegralDetails::class)->merge($zone, [], ['observations' => [$row]]);
// Synthetic public snapshot only, inside the rolled-back QA transaction.
$zone->forceFill(['publication_status' => 'published', 'validation_status' => 'validated',
'reviewer_id' => User::factory()->create(['email' => 'nature-review-'.Str::uuid().'@example.test'])->id,
'published_at' => now(), 'physical_category' => 'industrial_park',
'source_evidence' => [['source_type' => 'promoter', 'title' => 'SYNTHETIC QA only', 'url' => 'https://example.invalid/qa', 'supports' => 'Synthetic test fixture']],
'operational_details' => $ops->pack(['integral' => $fields])])->save();
$original = $zone->fresh()->getRawOriginal('operational_details');
$profile = app(InvestmentZoneOperationalProfile::class);
$prepared = $profile->prepare($zone->fresh(), ['version_no' => 1,
'operational_details' => ['integral' => ['observations' => [[...$row, 'measure_kind' => 'planned']]]]], $zone->editor_id);
$this->assertSame($original, $prepared->getRawOriginal('operational_details'));
$this->assertSame('observed', $ops->publicData($prepared)['integral']['observations'][0]['measure_kind']);
$this->assertSame('planned', $profile->draft($prepared)['fields']['operational_details']['integral']['observations'][0]['measure_kind']);
$this->assertSame(1, $prepared->version_no);
$this->assertSame(1, DB::table('investment_zone_revisions')->where('zone_id', $zone->id)->where('action', 'profile_prepared')->count());
$this->assertSame(1, DB::table('audit_events')->where('entity_id', $zone->id)->where('action', 'profile_prepared')->count());
}
}
@@ -226,6 +226,7 @@ test('comparison delta: metadata alignment never manufactures a trend or changes
const first = {
...row,
description: 'Emprego observado na fonte sintética.',
measure_kind: 'observed',
value: '1234567890123.456789',
};
const second = {
@@ -253,6 +254,7 @@ test('comparison delta: differences, missing evidence, unknown values and disput
const documented = {
...row,
description: 'Emprego observado na fonte sintética.',
measure_kind: 'observed',
};
const second = { ...documented, id: 'different' };
assert.equal(
@@ -292,6 +294,62 @@ test('comparison delta: differences, missing evidence, unknown values and disput
assert.match(html, /lacunas a conferir/);
});
test('measure kind delta: legacy nature stays unknown and observed quantities cannot align with plans or projections', () => {
const observed = {
...row,
description: 'Fonte sintética',
measure_kind: 'observed',
};
const same = { ...observed, id: 'second' };
assert.equal(compareObservations(observed, same).state, 'metadata_aligned');
for (const kind of ['planned', 'projected', 'unknown', null, undefined]) {
const other = { ...same, measure_kind: kind };
const before = JSON.stringify(other);
const result = compareObservations(observed, other);
assert.equal(result.state, 'review_required');
assert.ok(
result.issues.some((issue) =>
issue.includes('Natureza do valor diferente'),
),
);
assert.equal(JSON.stringify(other), before);
}
const legacy = compareObservations(
{ ...observed, measure_kind: undefined },
{ ...same, measure_kind: undefined },
);
assert.equal(legacy.state, 'review_required');
assert.ok(
legacy.issues.every((issue) =>
issue.includes('natureza do valor por confirmar'),
),
);
});
test('measure kind delta: public comparison labels nature independently of documentary confidence and retains zero', () => {
const first = {
...row,
description: 'Fonte sintética',
measure_kind: 'observed',
};
const second = { ...first, id: 'plan', measure_kind: 'planned' };
const html = renderToStaticMarkup(
React.createElement(Comparison, { first, second }),
);
assert.match(html, /Observado \/ efectivo/);
assert.match(html, /Planeado \/ meta/);
assert.match(html, /Documentada/);
assert.match(html, />0</);
const legacyHtml = renderToStaticMarkup(
React.createElement(Comparison, {
first: { ...first, measure_kind: undefined },
second,
}),
);
assert.match(legacyHtml, /natureza do valor por confirmar/);
assert.match(legacyHtml, /Por confirmar/);
});
test('comparison delta: progressive selection stays inside the received filters and shows an incomplete pair explicitly', () => {
const html = render(context);
assert.match(html, /Comparar duas observações/);