Classify sourced territorial observations without inferring legacy values
tests / ci (push) Waiting to run
tests / ci (push) Waiting to run
This commit is contained in:
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user