fix: reconcile accepted source classifications for protected investor aggregates
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-15 23:25:51 +02:00
parent c8957ecb9d
commit b60c231ddc
14 changed files with 11171 additions and 26 deletions
+22
View File
@@ -11,6 +11,28 @@ use Illuminate\Support\Facades\Schema;
/** Shared read contract. It deliberately has no dependency on the installation controller/service. */
class EconomicReferenceCatalog
{
/** Accepted source hierarchy for the private aggregate adapter; no catalogue publication or writes.
* @return array{source_sha256:string,nodes:list<array{id:string,code:string,label:string,group:string,group_code:string,group_label:string}>}
*/
public function supplyClassification(): array
{
$data = json_decode(CheckedFile::read(database_path('data/proactive-supply-classification.json')), true, 512, JSON_THROW_ON_ERROR);
abort_unless(($data['format'] ?? null) === 'accepted-source-classification-v1'
&& ($data['source_sha256'] ?? null) === '83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506', 503);
$nodes = [];
foreach ($data['nodes'] ?? [] as $node) {
if (! is_array($node) || ! is_string($node['id'] ?? null) || ! is_string($node['code'] ?? null)
|| ! is_string($node['label'] ?? null) || ! is_string($node['group'] ?? null)
|| ! is_string($node['group_code'] ?? null) || ! is_string($node['group_label'] ?? null)) {
abort(503, 'Referência de classificação indisponível.');
}
$nodes[] = ['id' => $node['id'], 'code' => $node['code'], 'label' => $node['label'], 'group' => $node['group'], 'group_code' => $node['group_code'], 'group_label' => $node['group_label']];
}
return ['source_sha256' => '83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506', 'nodes' => $nodes];
}
public const LOT = 'lot-01';
public const SOURCE = 'economic-reference-lot-01';
+4 -2
View File
@@ -197,8 +197,9 @@ final class InvestorContext
{
abort_unless(app(ProactiveSupply::class)->aggregateAvailable(), 404);
$protected = (bool) config('investor-path.protected_supply_enabled');
abort_if($protected && array_diff(array_keys($input), ['version', 'operation_key', 'activity_id']), 422, 'O território e o sector são recuperados do contexto guardado. Altere o contexto do dossier antes de consultar.');
$control = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid', 'activity_id' => 'nullable|uuid'])->validate();
abort_if($protected && array_diff(array_keys($input), ['version', 'operation_key', 'activity_id', 'classification_key']), 422, 'O território e o sector são recuperados do contexto guardado. Altere o contexto do dossier antes de consultar.');
$control = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid', 'activity_id' => 'nullable|uuid', 'classification_key' => ['nullable', 'string', 'max:80', 'regex:/^ebau:[0-9]+$/D']])->validate();
abort_if(! empty($control['activity_id']) && ! empty($control['classification_key']), 422, 'Seleccione uma única classificação para a comparação.');
$signature = hash('sha256', json_encode(\Illuminate\Support\Arr::except($control, ['version', 'operation_key']), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $id, $control, $protected, $signature): array {
$case = $this->desk->case($actor, $id, true, true);
@@ -218,6 +219,7 @@ final class InvestorContext
abort_if(count($context['opportunity_choices'] ?? []) > 1, 422, 'Esta análise utiliza um único contexto de investimento. Escolha o investimento a analisar, preservando as restantes associações.');
$f = $context['fields'];
if ($protected && array_key_exists('activity_id', $control)) $f['activity_id'] = $control['activity_id'];
if ($protected && ! empty($control['classification_key'])) $f['supply_classification_key'] = $control['classification_key'];
$territory = $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null;
abort_unless($territory, 422, 'Guarde primeiro a localização conhecida no dossier.');
$data = $protected ? app(ProactiveSupply::class)->protectedAggregate($actor, $id, $territory, $f) : app(ProactiveSupply::class)->aggregate($actor, $territory);
+67 -14
View File
@@ -80,10 +80,11 @@ final class ProactiveSupply
$activities = $this->publicAggregateActivities();
$divisions = $activities->filter(fn ($a) => preg_match('/^[0-9]{2}$/D', $a->code) === 1)->groupBy('code');
$sector = isset($fields['sector_id']) ? (int) $fields['sector_id'] : null;
abort_unless($divisions->contains(fn ($references) => $references->contains(fn ($a) => ! $sector || (int) $a->effective_sector_id === $sector)), 422,
'A classificação de actividades aplicável a este sector ainda não está disponível para esta consulta. O dossier mantém-se guardado; esta limitação não significa ausência de empresas.');
$activity = ! empty($fields['activity_id']) ? $activities->firstWhere('id', $fields['activity_id']) : null;
abort_if(! empty($fields['activity_id']) && ! $activity, 422, 'A actividade guardada não está disponível nesta classificação. Reveja o contexto.');
$sourcePlan = ! empty($fields['supply_classification_key']) ? $this->sourceClassificationPlan($fields['supply_classification_key']) : null;
abort_unless($sourcePlan || $divisions->contains(fn ($references) => $references->contains(fn ($a) => ! $sector || (int) $a->effective_sector_id === $sector)), 422,
'Escolha a actividade a comparar na classificação das fontes. O sector do dossier mantém-se guardado; não se presume uma equivalência. Esta limitação não significa ausência de empresas.');
$activity = ! $sourcePlan && ! empty($fields['activity_id']) ? $activities->firstWhere('id', $fields['activity_id']) : null;
abort_if(! $sourcePlan && ! empty($fields['activity_id']) && ! $activity, 422, 'A actividade guardada não está disponível nesta classificação. Reveja o contexto.');
abort_if($activity && $sector && (int) $activity->effective_sector_id !== $sector, 422, 'A actividade não corresponde ao sector guardado. Reveja o contexto.');
$rawActivityCode = $activity?->code;
abort_if($activity && ! is_string($rawActivityCode), 422, 'A referência de actividade não tem um código válido.');
@@ -92,7 +93,7 @@ final class ProactiveSupply
$division = $activityCode ? substr($activityCode, 0, 2) : null;
$previous = DB::selectOne("select current_setting('app.actor_user_id',true) as actor,current_setting('app.proactive_supply_read',true) as scope");
return $this->read($actor, function () use ($mapping, $scopes, $province, $scope, $territory, $path, $divisions, $sector, $division, $activity, $previous): array {
return $this->read($actor, function () use ($mapping, $scopes, $province, $scope, $territory, $path, $divisions, $sector, $division, $activity, $previous, $sourcePlan): array {
try {
$parts = [];
$bindings = [];
@@ -103,7 +104,12 @@ final class ProactiveSupply
}
$scopeSql = 'CASE o.territory_id::text '.implode(' ', $parts).' ELSE NULL END';
$codeSql = "CASE WHEN replace(o.cae,'.','') ~ '^[0-9]{2,5}$' THEN substring(replace(o.cae,'.','') from 1 for 2) ELSE NULL END";
$rows = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash')->whereIn('o.territory_id', array_keys($mapping))
$query = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash');
if ($sourcePlan) {
$this->joinSourceClassification($query, $sourcePlan);
$codeSql = $this->sourceClassificationExpression();
}
$rows = $query->whereIn('o.territory_id', array_keys($mapping))
->whereRaw("s.receipt->>'candidate' = ? AND s.receipt->>'access' = 'internal'", ['a022ed9848b41ba342b4209414c2bbf2ebd0a7a3'])
->whereNull('o.duplicate_of')->where('o.identity_conflict', false)->whereRaw("trim(o.name) <> ''")
// The accepted importer uses an observation-specific key when NUIT is unresolved.
@@ -114,14 +120,14 @@ final class ProactiveSupply
$groups = [];
foreach ($rows as $row) {
// Suppressed and absent cells have the same public representation. No marginal totals.
if ((int) $row->entities < 5 || ! $row->code || ! $divisions->has($row->code)) {
if ((int) $row->entities < 5 || ! $row->code || (! $sourcePlan && ! $divisions->has($row->code))) {
continue;
}
$references = $divisions->get($row->code);
if ($sector && ! $references->contains(fn ($a) => (int) $a->effective_sector_id === $sector)) {
$references = $divisions->get($row->code, collect());
if (! $sourcePlan && $sector && ! $references->contains(fn ($a) => (int) $a->effective_sector_id === $sector)) {
continue;
}
if ($division && $row->code !== $division) {
if (($sourcePlan && $row->code !== $sourcePlan['selected']['group']) || (! $sourcePlan && $division && $row->code !== $division)) {
continue;
}
if ($scope->id !== $province->id && ! in_array($row->scope_id, [$scope->id, $province->id], true)) {
@@ -129,7 +135,8 @@ final class ProactiveSupply
}
$labels = $references->pluck('label_pt_mz')->unique();
$cellScope = $scopes[$row->scope_id];
$groups[] = ['code' => $row->code, 'label' => $labels->count() === 1 ? $labels->first() : 'Grupo CAE '.$row->code,
$groups[] = ['code' => $sourcePlan ? $sourcePlan['selected']['group_code'] : $row->code, 'label' => $sourcePlan ? $sourcePlan['selected']['group_label'] : ($labels->count() === 1 ? $labels->first() : 'Grupo CAE '.$row->code),
...($sourcePlan ? ['classification_key' => $row->code] : []),
'total' => null, 'entities_band' => $this->countBand((int) $row->entities), 'observations_band' => $this->countBand((int) $row->observations),
'scope' => ['id' => $cellScope->id, 'name' => $cellScope->name, 'level' => $cellScope->location_type], 'first_year' => null, 'last_year' => null];
}
@@ -150,7 +157,8 @@ final class ProactiveSupply
'scope' => ['id' => $scope->id, 'name' => $scope->name, 'level' => $scope->location_type],
'broader_context' => $scope->id !== $territory, 'entities' => null, 'repeated' => null, 'uncertain' => null,
'groups' => $groups, 'truncated' => false, 'coverage_unknown' => true,
'filters' => ['sector_id' => $sector, 'activity_id' => $activity?->id, 'activity_label' => $activity?->label_pt_mz, 'division_code' => $division],
'filters' => ['sector_id' => $sector, 'activity_id' => $activity?->id, 'activity_label' => $activity?->label_pt_mz, 'division_code' => $sourcePlan ? $sourcePlan['selected']['group_code'] : $division,
...($sourcePlan ? ['classification_key' => $sourcePlan['selected']['id'], 'classification_label' => $sourcePlan['selected']['label'], 'classification_basis' => 'Hierarquia e designações do exemplar eBAU já utilizado nas fontes; correspondência por código ou designação exacta, sem equivalência presumida ao sector do dossier.'] : [])],
'source_summary' => ['label' => 'Base administrativa empresarial histórica incorporada no portal', 'period' => $period, 'incomplete_periods' => $unknownPeriod],
'limitations' => self::LIMITATIONS,
'interpretation' => 'Intervalos de cinco identidades documentais, nunca empresas únicas confirmadas. Observações consolidadas e identidades são medidas distintas. Uma identidade pode constar em várias actividades; não some grupos. Grupos com menos de cinco identidades, conflitos e dados sem classificação publicada não são divulgados; nenhum resultado pode significar ausência de registos ou protecção de informação.',
@@ -168,6 +176,47 @@ final class ProactiveSupply
return $lower.'–'.($lower + 4);
}
/** @return array<string,mixed> */
private function sourceClassificationPlan(string $key): array
{
$nodes = collect(app(EconomicReferenceCatalog::class)->supplyClassification()['nodes']);
$selected = $nodes->firstWhere('id', $key);
abort_unless($selected !== null, 422, 'A classificação seleccionada não consta do exemplar disponível.');
$codes = $labels = [];
foreach ($nodes->groupBy(fn ($n) => mb_strtoupper(str_replace('.', '', trim($n['code'])))) as $code => $items) {
if ($items->pluck('group')->unique()->count() === 1) {
$codes[] = [(string) $code, $items->first()['group']];
}
}
foreach ($nodes->groupBy(fn ($n) => mb_strtolower(trim($n['label']))) as $label => $items) {
if ($items->pluck('group')->unique()->count() === 1) {
$labels[] = [(string) $label, $items->first()['group']];
}
}
return ['selected' => $selected, 'codes' => $codes, 'labels' => $labels];
}
/** @param array<string,mixed> $plan */
private function joinSourceClassification(Builder $query, array $plan): void
{
foreach (['codes' => ['class_code', 'upper(replace(btrim(o.cae),\'.\',\'\'))'], 'labels' => ['class_label', "translate(lower(btrim(o.activity)),'ÁÀÂÃÉÊÍÓÔÕÚÜÇ','áàâãéêíóôõúüç')"]] as $kind => [$alias, $expression]) {
$rows = $plan[$kind];
$values = implode(',', array_fill(0, count($rows), '(?::text,?::text)'));
$query->leftJoin(DB::raw('(VALUES '.$values.') AS '.$alias.'(value,group_id)'), function ($join) use ($alias, $expression) {
$join->on(DB::raw($expression), '=', $alias.'.value');
})->addBinding(array_merge(...$rows), 'join');
}
}
/** @return literal-string */
private function sourceClassificationExpression(): string
{
// Already accepted tourism import: documentary scope is accommodation, not a certified hotel subtype.
return "CASE WHEN o.source_hash='f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c' AND btrim(o.cae)='' AND o.activity='Alojamento (âmbito do exemplar; categoria por conferir)' THEN 'ebau:158'
WHEN class_code.group_id IS NOT NULL THEN CASE WHEN btrim(o.activity)='' OR class_label.group_id=class_code.group_id THEN class_code.group_id ELSE NULL END ELSE class_label.group_id END";
}
/** @return Collection<int,\stdClass> */
private function publicAggregateActivities(): Collection
{
@@ -206,6 +255,10 @@ final class ProactiveSupply
$result[] = ['id' => $a->id, 'code' => $a->code, 'label' => $a->label_pt_mz];
}
foreach (app(EconomicReferenceCatalog::class)->supplyClassification()['nodes'] as $node) {
$result[] = ['id' => $node['id'], 'code' => $node['code'], 'label' => $node['label']];
}
return $result;
}
@@ -227,9 +280,9 @@ final class ProactiveSupply
if (! preg_match('/^[0-9]{2}$/D', $group['code'] ?? '') || ! $this->validBand($group['entities_band'] ?? '') || ! $this->validBand($group['observations_band'] ?? '')) {
continue;
}
$safe['groups'][] = [...Arr::only($group, ['code', 'label', 'entities_band', 'observations_band', 'scope']), 'total' => null, 'first_year' => null, 'last_year' => null];
$safe['groups'][] = [...Arr::only($group, ['code', 'label', 'entities_band', 'observations_band', 'scope', 'classification_key']), 'total' => null, 'first_year' => null, 'last_year' => null];
}
$safe['filters'] = Arr::only($row['filters'] ?? [], ['sector_id', 'activity_id', 'activity_label', 'division_code']);
$safe['filters'] = Arr::only($row['filters'] ?? [], ['sector_id', 'activity_id', 'activity_label', 'division_code', 'classification_key', 'classification_label', 'classification_basis']);
$safe['source_summary'] = Arr::only($row['source_summary'] ?? [], ['label', 'period', 'incomplete_periods']);
$result[] = $safe;
}
File diff suppressed because it is too large Load Diff
@@ -10303,3 +10303,20 @@ Recibo: `outputs/aggregate-supply-20261115/qa-verified.json`; capturas em `outpu
**Bloqueante confirmado no destino:** a leitura técnica autenticada encontrou apenas cinco códigos gerais publicados (`agronegocio`, `economia-azul`, `infraestruturas`, `mercado-financeiro`, `turismo`) e nenhuma divisão CAE numérica publicada. Os quatro exemplares internos têm o contrato reconhecido e existem grupos documentados não singulares, mas a classificação não é compatível com o adaptador. As dez provas usam referências publicadas sintéticas em QA; não demonstram disponibilidade da classificação no destino. O recorte original CAE A/01/011/0112/01120 está preparado em lote existente e não cobre os cinco sectores. Não se publicou esse lote nem se presumiram correspondências CAE → sectores. O servidor devolve 422 com explicação compreensível quando falta a classificação, sem gravar um resultado falso ou alterar o dossier. O teste adicional passou sem desactivar a guarda de imutabilidade das referências. Instalação suspensa, homologação/main em d3067050, sem reserva, migração, alteração de configuração ou escrita de dados. É necessário reconciliar as referências originais CAE e as correspondências sectoriais no catálogo existente e obter a respectiva validação/publicação editorial, antes de habilitar o delta. A autorização de consulta agregada não publica automaticamente outro lote de referências (AGENTS.md: «Nunca publicar automaticamente um lote»). Isto não exige nem autoriza acesso a empresas individuais. Não existe neste momento um pacote apto para instalação útil nos cinco sectores.
## Reconciliação real e remoção do bloqueio de classificação — 15/11/2026
A instrução seguinte autorizou continuar `c8957ecb` e confirmou que divisões CAE numéricas publicadas não são um requisito universal. O bloqueio descrito acima era uma escolha de implementação, agora corrigida. O adaptador privado reutiliza o contrato existente `EconomicReferenceCatalog`, o exemplar CAE eBAU já aceite (SHA256 `83b4f120…506`) e a hierarquia SELF_ID/PARENT_ID: 1 339 nós com grupo amplo identificado, 88 grupos. Não cria outro cadastro, não insere/publica referências gerais e não altera os cinco códigos gerais publicados. Código demonstrado ou designação exacta são utilizáveis; diferenças de maiúsculas com acentos são tratadas de forma determinística, incluindo PostgreSQL com locale C.
Não se inventam equivalências CAE → doze sectores do portal. O sector, intenção, território e orçamento mantêm-se no dossier; o utilizador escolhe a classificação da comparação. Correspondências publicadas já utilizáveis continuam disponíveis. «HOTÉIS COM RESTAURANTE» conserva o grupo amplo Alojamento; Restaurantes conserva Restauração e similares. O resultado explicita o agrupamento amplo, sem o apresentar como contagem exclusiva do subtipo seleccionado. O enquadramento amplo de alojamento do exemplar turístico, aceite anteriormente, é reutilizado apenas para essa fonte e sem inventar subtipo. Os seus registos sem identidade resolvida continuam excluídos dos limiares de divulgação. Não se tornam empresas únicas por terem uma actividade identificada.
A reconciliação das observações reais separou correspondências por código, enquadramento documental amplo e ausência de classificação. Não encontrou pares concretos código/designação contraditórios na fotografia aceite; o teste de conflitos garante a sua exclusão quando existam. A ausência de classificação e as situações ambíguas não são convertidas em equivalências nem em ausência de actividade. A edição normativa do exemplar e os tipos específicos do recorte turístico continuam indeterminados. Não surgiu uma decisão editorial bloqueante para as correspondências demonstradas; não se publicou automaticamente nenhum lote.
Prova real: leitura técnica autenticada SSH/PostgreSQL em transacção READ ONLY, usando o compilador de classificação da candidata, geografia pública canónica e as mesmas protecções de identidade, duplicados, grupo mínimo e intervalos. Retornaram-se apenas oito células agregadas de exemplo: em Cidade de Nampula, 25–29 identidades documentais em Alojamento e 290–294 em Restauração. São intervalos documentais históricos, não empresas únicas certificadas, clientes, procura ou cobertura nacional integral. Não se devolveram linhas, nomes, contactos, documentos ou identificadores individuais. Esta leitura técnica não é apresentada como login de um investidor operacional; as permissões e a interface de proprietário foram verificadas separadamente em QA, com contas sintéticas.
Delta verificado: 3 testes HTTP/49 asserções e 1 teste/12 asserções para o enquadramento turístico; três testes frontend afectados, PHPStan, tipos, lint, Pint e compilação normal/Wayfinder aprovados. Quatro novas execuções autenticadas (hotéis/restaurantes × computador/390 px) demonstraram escolha por designação, gravação, recuperação, manutenção do sector e apenas intervalos protegidos. Emulação móvel, sem alegar telefone físico. Reutilizadas as provas aceites de `c8957ecb`, sem outra ronda geral. Recibos `outputs/aggregate-supply-20261115/source-reconciliation-qa.json`, `source-classification-reconciliation.json` e `real-protected-aggregates.json`.
Utilização: no próprio dossier aberto, «Actividades existentes na localização» → pesquisar a actividade pelos primeiros três caracteres ou código → «Consultar e guardar análise agregada». A análise, classificação e histórico são privados e recuperáveis. A escolha não substitui a actividade do investimento. Sem correspondência previamente demonstrada, pede-se a escolha da classificação em vez de presumir equivalência entre taxonomias. Grupos pequenos, conflitos e falta de identidade/classificação conservam a explicação de cobertura insuficiente, nunca «não existem empresas».
Estado desta passagem: candidato verificado, instalação já autorizada e ainda não executada neste registo. Prosseguir pelo executor Git com capacidade, backup/restauro frescos, pin da revisão, activação apenas de `INVESTOR_PROTECTED_SUPPLY_ENABLED` e conferência do delta. Sem migrações, novos papéis, exposição individual ou produção. Ofertas, dashboard, correcção financeira e d3067050 preservados. Tradução permanece separada.
@@ -342,3 +342,20 @@ Recibo: `outputs/aggregate-supply-20261115/qa-verified.json`; capturas em `outpu
**Bloqueante confirmado no destino:** a leitura técnica autenticada encontrou apenas cinco códigos gerais publicados (`agronegocio`, `economia-azul`, `infraestruturas`, `mercado-financeiro`, `turismo`) e nenhuma divisão CAE numérica publicada. Os quatro exemplares internos têm o contrato reconhecido e existem grupos documentados não singulares, mas a classificação não é compatível com o adaptador. As dez provas usam referências publicadas sintéticas em QA; não demonstram disponibilidade da classificação no destino. O recorte original CAE A/01/011/0112/01120 está preparado em lote existente e não cobre os cinco sectores. Não se publicou esse lote nem se presumiram correspondências CAE → sectores. O servidor devolve 422 com explicação compreensível quando falta a classificação, sem gravar um resultado falso ou alterar o dossier. O teste adicional passou sem desactivar a guarda de imutabilidade das referências. Instalação suspensa, homologação/main em d3067050, sem reserva, migração, alteração de configuração ou escrita de dados. É necessário reconciliar as referências originais CAE e as correspondências sectoriais no catálogo existente e obter a respectiva validação/publicação editorial, antes de habilitar o delta. A autorização de consulta agregada não publica automaticamente outro lote de referências (AGENTS.md: «Nunca publicar automaticamente um lote»). Isto não exige nem autoriza acesso a empresas individuais. Não existe neste momento um pacote apto para instalação útil nos cinco sectores.
## Reconciliação real e remoção do bloqueio de classificação — 15/11/2026
A instrução seguinte autorizou continuar `c8957ecb` e confirmou que divisões CAE numéricas publicadas não são um requisito universal. O bloqueio descrito acima era uma escolha de implementação, agora corrigida. O adaptador privado reutiliza o contrato existente `EconomicReferenceCatalog`, o exemplar CAE eBAU já aceite (SHA256 `83b4f120…506`) e a hierarquia SELF_ID/PARENT_ID: 1 339 nós com grupo amplo identificado, 88 grupos. Não cria outro cadastro, não insere/publica referências gerais e não altera os cinco códigos gerais publicados. Código demonstrado ou designação exacta são utilizáveis; diferenças de maiúsculas com acentos são tratadas de forma determinística, incluindo PostgreSQL com locale C.
Não se inventam equivalências CAE → doze sectores do portal. O sector, intenção, território e orçamento mantêm-se no dossier; o utilizador escolhe a classificação da comparação. Correspondências publicadas já utilizáveis continuam disponíveis. «HOTÉIS COM RESTAURANTE» conserva o grupo amplo Alojamento; Restaurantes conserva Restauração e similares. O resultado explicita o agrupamento amplo, sem o apresentar como contagem exclusiva do subtipo seleccionado. O enquadramento amplo de alojamento do exemplar turístico, aceite anteriormente, é reutilizado apenas para essa fonte e sem inventar subtipo. Os seus registos sem identidade resolvida continuam excluídos dos limiares de divulgação. Não se tornam empresas únicas por terem uma actividade identificada.
A reconciliação das observações reais separou correspondências por código, enquadramento documental amplo e ausência de classificação. Não encontrou pares concretos código/designação contraditórios na fotografia aceite; o teste de conflitos garante a sua exclusão quando existam. A ausência de classificação e as situações ambíguas não são convertidas em equivalências nem em ausência de actividade. A edição normativa do exemplar e os tipos específicos do recorte turístico continuam indeterminados. Não surgiu uma decisão editorial bloqueante para as correspondências demonstradas; não se publicou automaticamente nenhum lote.
Prova real: leitura técnica autenticada SSH/PostgreSQL em transacção READ ONLY, usando o compilador de classificação da candidata, geografia pública canónica e as mesmas protecções de identidade, duplicados, grupo mínimo e intervalos. Retornaram-se apenas oito células agregadas de exemplo: em Cidade de Nampula, 25–29 identidades documentais em Alojamento e 290–294 em Restauração. São intervalos documentais históricos, não empresas únicas certificadas, clientes, procura ou cobertura nacional integral. Não se devolveram linhas, nomes, contactos, documentos ou identificadores individuais. Esta leitura técnica não é apresentada como login de um investidor operacional; as permissões e a interface de proprietário foram verificadas separadamente em QA, com contas sintéticas.
Delta verificado: 3 testes HTTP/49 asserções e 1 teste/12 asserções para o enquadramento turístico; três testes frontend afectados, PHPStan, tipos, lint, Pint e compilação normal/Wayfinder aprovados. Quatro novas execuções autenticadas (hotéis/restaurantes × computador/390 px) demonstraram escolha por designação, gravação, recuperação, manutenção do sector e apenas intervalos protegidos. Emulação móvel, sem alegar telefone físico. Reutilizadas as provas aceites de `c8957ecb`, sem outra ronda geral. Recibos `outputs/aggregate-supply-20261115/source-reconciliation-qa.json`, `source-classification-reconciliation.json` e `real-protected-aggregates.json`.
Utilização: no próprio dossier aberto, «Actividades existentes na localização» → pesquisar a actividade pelos primeiros três caracteres ou código → «Consultar e guardar análise agregada». A análise, classificação e histórico são privados e recuperáveis. A escolha não substitui a actividade do investimento. Sem correspondência previamente demonstrada, pede-se a escolha da classificação em vez de presumir equivalência entre taxonomias. Grupos pequenos, conflitos e falta de identidade/classificação conservam a explicação de cobertura insuficiente, nunca «não existem empresas».
Estado desta passagem: candidato verificado, instalação já autorizada e ainda não executada neste registo. Prosseguir pelo executor Git com capacidade, backup/restauro frescos, pin da revisão, activação apenas de `INVESTOR_PROTECTED_SUPPLY_ENABLED` e conferência do delta. Sem migrações, novos papéis, exposição individual ou produção. Ofertas, dashboard, correcção financeira e d3067050 preservados. Tradução permanece separada.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
{
"state": "real_protected_aggregate_proof",
"read_only": true,
"individual_records_returned": false,
"source_groups": 88,
"policy_sql_sha256": "dfa537cbf8ca549fe9f07967bc63c5cccddd66561f1231e66845fb7a950450b9",
"cells": [
{
"territory_id": "9780e189-01aa-5cca-9efb-fb395c9ac04f",
"territory": "MANHIÇA",
"scope": "district",
"classification_key": "ebau:110",
"identities_band": "185–189",
"observations_band": "190–194"
},
{
"territory_id": "d4fb92cc-d278-5f69-9490-a98e6dfef18d",
"territory": "MONTEPUEZ",
"scope": "district",
"classification_key": "ebau:110",
"identities_band": "140–144",
"observations_band": "150–154"
},
{
"territory_id": "faf26fdf-4818-5dfd-9527-c5cd39a226cd",
"territory": "CIDADE DE PEMBA",
"scope": "district",
"classification_key": "ebau:147",
"identities_band": "125–129",
"observations_band": "130–134"
},
{
"territory_id": "e3d3711f-f563-512f-838c-77bc8548d930",
"territory": "CIDADE DA BEIRA",
"scope": "district",
"classification_key": "ebau:147",
"identities_band": "70–74",
"observations_band": "70–74"
},
{
"territory_id": "e6c437f4-08dd-51a1-be84-27e8604cd84c",
"territory": "CIDADE DE NAMPULA",
"scope": "district",
"classification_key": "ebau:158",
"identities_band": "25–29",
"observations_band": "25–29"
},
{
"territory_id": "38ccaaf2-26ef-5728-b50f-b9d416a5e036",
"territory": "CIDADE DE CHIMOIO",
"scope": "district",
"classification_key": "ebau:158",
"identities_band": "20–24",
"observations_band": "20–24"
},
{
"territory_id": "e6c437f4-08dd-51a1-be84-27e8604cd84c",
"territory": "CIDADE DE NAMPULA",
"scope": "district",
"classification_key": "ebau:159",
"identities_band": "290–294",
"observations_band": "295–299"
},
{
"territory_id": "9f083d3c-811e-504a-bcd1-a37cd621956d",
"territory": "CIDADE DE TETE",
"scope": "district",
"classification_key": "ebau:159",
"identities_band": "200–204",
"observations_band": "200–204"
}
],
"national_coverage": "partial; not claimed exhaustive",
"operational_account_impersonation": false,
"application_owner_authorisation": "separately tested in isolated QA"
}
@@ -0,0 +1,17 @@
{
"source_snapshot": "accepted internal delivery a022ed98; original sources retained",
"classification_source_sha256": "83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506",
"reference_nodes": 1339,
"fixed_groups": 88,
"source_classification_observations_bands": {
"demonstrated_source_code": "278305–278309",
"unclassified": "82875–82879",
"documented_accommodation_scope_specific_type_unconfirmed": "275–279"
},
"concrete_conflicting_correspondences": [],
"portal_sector_crosswalk": "not inferred; source classification selected explicitly; context and published existing matches retained",
"specific_tourism_subtype": "not inferred from broad source; does not block accommodation classification",
"unidentified_documentary_entities": "excluded from protected cohorts, even when activity classification is known",
"classification_publication": false,
"national_coverage": "partial; original absence and unmapped records retained"
}
@@ -0,0 +1,104 @@
{
"state": "QA_VERIFIED_INSTALLATION_AUTHORISED_NOT_YET_EXECUTED",
"previous_qa": "c8957ecb",
"installed_baseline": "d3067050",
"reuse": "6 HTTP tests/218 assertions and ten synthetic desktop/mobile journeys accepted in c8957ecb; only changed classification consumers retested",
"delta_tests": {
"classification_http_tests": 3,
"classification_assertions": 49,
"accepted_tourism_scope_test": 1,
"accepted_tourism_scope_assertions": 12,
"frontend": 3,
"phpstan": "passed; includes EconomicReferenceCatalog",
"types": "passed",
"build": "passed; normal Wayfinder",
"lint": "passed",
"pint": "passed"
},
"browser": [
{
"key": "ebau:979",
"width": 1440,
"status": "APROVADO",
"sameDossier": "29615b64-2438-4821-a1b5-d6ee6d59ce85",
"sourceChoiceRecovered": true,
"ownerOnly": true,
"countsProtected": true,
"sectorContextPreserved": true,
"viewportEmulation": false
},
{
"key": "ebau:979",
"width": 390,
"status": "APROVADO",
"sameDossier": "29615b64-2438-4821-a1b5-d6ee6d59ce85",
"sourceChoiceRecovered": true,
"ownerOnly": true,
"countsProtected": true,
"sectorContextPreserved": true,
"viewportEmulation": true
},
{
"key": "ebau:345",
"width": 1440,
"status": "APROVADO",
"sameDossier": "29615b64-2438-4821-a1b5-d6ee6d59ce85",
"sourceChoiceRecovered": true,
"ownerOnly": true,
"countsProtected": true,
"sectorContextPreserved": true,
"viewportEmulation": false
},
{
"key": "ebau:345",
"width": 390,
"status": "APROVADO",
"sameDossier": "29615b64-2438-4821-a1b5-d6ee6d59ce85",
"sourceChoiceRecovered": true,
"ownerOnly": true,
"countsProtected": true,
"sectorContextPreserved": true,
"viewportEmulation": true
}
],
"real_aggregate_proof": "real-protected-aggregates.json; authenticated technical SSH/read-only PostgreSQL; no operational user impersonated; owner authorisation/UI separately demonstrated in QA",
"real_cells": 8,
"source_classification": {
"source_snapshot": "accepted internal delivery a022ed98; original sources retained",
"classification_source_sha256": "83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506",
"reference_nodes": 1339,
"fixed_groups": 88,
"source_classification_observations_bands": {
"demonstrated_source_code": "278305–278309",
"unclassified": "82875–82879",
"documented_accommodation_scope_specific_type_unconfirmed": "275–279"
},
"concrete_conflicting_correspondences": [],
"portal_sector_crosswalk": "not inferred; source classification selected explicitly; context and published existing matches retained",
"specific_tourism_subtype": "not inferred from broad source; does not block accommodation classification",
"unidentified_documentary_entities": "excluded from protected cohorts, even when activity classification is known",
"classification_publication": false,
"national_coverage": "partial; original absence and unmapped records retained"
},
"privacy": "minimum five resolved documentary identities; fixed district/coarse source-classification cells; bands; no marginal totals; no individual names/contacts/files/exports; originals unchanged",
"public_reference_catalogue_changes": false,
"new_migrations": [],
"new_roles": false,
"production": false,
"national_coverage": "partial; not exhaustive",
"translation": "separate pending dependency",
"sha256": {
"app/Services/ProactiveSupply.php": "4aa474cdad93b501e010e3c81037166a5a99504081f0543541112964d488403e",
"app/Services/EconomicReferenceCatalog.php": "d8f6113e282b4b3cabab1b5631f97c21e8dea28411a488e6cdc6c0ea92bd0f28",
"app/Services/InvestorContext.php": "89148a74a508ca14e22a7a50a3458587377c8d957f1ca8834b30dc50ec3572c6",
"app/Services/InvestorPath.php": "eae30cb00f1c476b5ebd79909a1a338a70775337cf7016f15d5ecbe6e13b9bd3",
"config/investor-path.php": "571862669a02421bbeea3b95e05737d393ed6c8d3afb507f0d0aa74d037f89ca",
"database/data/proactive-supply-classification.json": "485205c923cb157c2e3572b7cb9818b6423506fa67338b12c939151c0a86db71",
"resources/js/components/investment-supply-aggregate.tsx": "d0ee2f34b641eecf80d03a3760621a0137de8c2045e24bcac508b8afccadb262",
"resources/js/lib/investor-context.ts": "ede1bd84108ccd73376f248f98f500273add133d134e931c49a6a589e1f6db7d",
"resources/js/pages/investor-path/show.tsx": "66b9a2e86843a93d5b52f73a198de4a2f4e576ca9f699fa9a0f61eb023f51604",
"tests/Feature/ProtectedSupplyAggregateTest.php": "ce199c7ce4bda30ee20df54db46a8436dfdcaf9a8b544eeb84a44f30a54e06a4",
"tests/frontend/protected-supply-aggregate.test.mjs": "9fe058ddcb479bd1a8370887acc75ba9799fc8af4050109867b8c7bb4be76682"
},
"timestamp_utc": "2026-11-15T21:25:48.971522+00:00"
}
@@ -0,0 +1,46 @@
[
{
"key": "ebau:979",
"width": 1440,
"status": "APROVADO",
"sameDossier": "29615b64-2438-4821-a1b5-d6ee6d59ce85",
"sourceChoiceRecovered": true,
"ownerOnly": true,
"countsProtected": true,
"sectorContextPreserved": true,
"viewportEmulation": false
},
{
"key": "ebau:979",
"width": 390,
"status": "APROVADO",
"sameDossier": "29615b64-2438-4821-a1b5-d6ee6d59ce85",
"sourceChoiceRecovered": true,
"ownerOnly": true,
"countsProtected": true,
"sectorContextPreserved": true,
"viewportEmulation": true
},
{
"key": "ebau:345",
"width": 1440,
"status": "APROVADO",
"sameDossier": "29615b64-2438-4821-a1b5-d6ee6d59ce85",
"sourceChoiceRecovered": true,
"ownerOnly": true,
"countsProtected": true,
"sectorContextPreserved": true,
"viewportEmulation": false
},
{
"key": "ebau:345",
"width": 390,
"status": "APROVADO",
"sameDossier": "29615b64-2438-4821-a1b5-d6ee6d59ce85",
"sourceChoiceRecovered": true,
"ownerOnly": true,
"countsProtected": true,
"sectorContextPreserved": true,
"viewportEmulation": true
}
]
@@ -36,6 +36,7 @@ export default function InvestmentSupplyAggregate({
version: number;
operation_key: string;
activity_id?: string | null;
classification_key?: string;
} | null>(null);
const [selectedHistory, setSelectedHistory] = useState<number | null>(null);
const latest =
@@ -43,7 +44,9 @@ export default function InvestmentSupplyAggregate({
const protectedView = latest?.privacy_version === 1;
const [activityId, setActivityId] = useState(
history.at(-1)?.context_revision === revision
? (history.at(-1)?.filters?.activity_id ?? '')
? (history.at(-1)?.filters?.classification_key ??
history.at(-1)?.filters?.activity_id ??
'')
: (defaultActivityId ?? ''),
);
return (
@@ -92,7 +95,12 @@ export default function InvestmentSupplyAggregate({
version,
operation_key: createOperationKey(),
...(protectedMode
? { activity_id: activityId || null }
? activityId.startsWith('ebau:')
? {
activity_id: null,
classification_key: activityId,
}
: { activity_id: activityId || null }
: {}),
};
try {
@@ -194,15 +202,19 @@ export default function InvestmentSupplyAggregate({
entidade.
</p>
<p>
Filtros recuperados do dossier:{' '}
{latest.filters?.activity_label
? `actividade ${latest.filters.activity_label}, apresentada ao nível da divisão CAE ${latest.filters.division_code}`
Filtros recuperados da análise guardada:{' '}
{(latest.filters?.classification_label ??
latest.filters?.activity_label)
? `actividade ${latest.filters.classification_label ?? latest.filters.activity_label}, apresentada no grupo ${latest.filters.division_code}`
: latest.filters?.sector_id
? 'sector escolhido e actividades documentadas na classificação publicada'
: 'todas as actividades com classificação publicada'}
. Nenhum montante, contacto ou empresa
individual é apresentado.
</p>
{latest.filters?.classification_basis && (
<p>{latest.filters.classification_basis}</p>
)}
<p>
São apresentados intervalos, sem totais exactos.
Resultados pequenos ou insuficientemente
+3
View File
@@ -45,6 +45,9 @@ export type SupplyAggregate = {
sector_id: number | null;
activity_id: string | null;
activity_label: string | null;
classification_key?: string;
classification_label?: string;
classification_basis?: string;
division_code: string | null;
};
context_revision: number;
+57 -1
View File
@@ -214,7 +214,63 @@ final class ProtectedSupplyAggregateTest extends TestCase
$after = $this->getJson('/investimentos/'.$id)->assertOk()->json();
$this->assertSame($before['case']['version'], $after['case']['version']);
$this->assertSame([], $after['supply_aggregates']);
$this->assertSame([], $after['supply_activity_options']);
$this->assertContains('ebau:979', array_column($after['supply_activity_options'], 'id'));
}
public function test_accepted_source_classification_works_by_code_or_exact_label_without_public_numeric_catalogue(): void
{
$sector = Sector::create(['name' => 'SYNTHETIC general tourism', 'slug' => 'qa-'.Str::lower(Str::random(16)), 'is_active' => true]);
$this->sectors['source_only'] = $sector->id;
$id = $this->dossier('source_only');
DB::table('proactive_supply_observations')->where('source_hash', $this->sourceHash)->where('cae', '55110')->update(['cae' => '55111', 'activity' => 'HOTÉIS COM RESTAURANTE']);
$coded = $this->aggregateSnapshot($id, ['classification_key' => 'ebau:979']);
$this->assertSame('55', $coded['groups'][0]['code']);
$this->assertSame('5–9', $coded['groups'][0]['entities_band']);
$this->assertSame('ebau:979', $coded['filters']['classification_key']);
$this->assertSame($sector->id, $coded['filters']['sector_id']);
DB::table('proactive_supply_observations')->where('source_hash', $this->sourceHash)->where('cae', '55111')->update(['cae' => '']);
$labelled = $this->aggregateSnapshot($id, ['classification_key' => 'ebau:979']);
$this->assertSame($coded['groups'], $labelled['groups']);
for ($i = 0; $i < 6; $i++) {
$row = $this->observation($this->sourceHash, '56', 'restaurant-'.$i, $this->geo['large']);
DB::table('proactive_supply_observations')->where('id', $row)->update(['cae' => '', 'activity' => 'RESTAURANTES (INCLUI ACTIVIDADES DE RESTAURAÇÃO EM MEIOS MÓVEIS)']);
}
$restaurant = $this->aggregateSnapshot($id, ['classification_key' => 'ebau:345']);
$this->assertSame('56', $restaurant['groups'][0]['code']);
$this->assertSame('5–9', $restaurant['groups'][0]['entities_band']);
$after = $this->getJson('/investimentos/'.$id)->assertOk()->json();
$this->assertCount(3, $after['supply_aggregates']);
$this->assertSame('ebau:345', $after['supply_aggregates'][2]['filters']['classification_key']);
$this->assertStringNotContainsString('PRIVATE-', json_encode($after['supply_aggregates'], JSON_THROW_ON_ERROR));
$this->assertSame('1000000', $after['investment_context']['fields']['investment_budget_min']);
}
public function test_conflicting_and_unidentified_source_classifications_are_not_invented_matches(): void
{
$id = $this->dossier('hotel');
DB::table('proactive_supply_observations')->where('source_hash', $this->sourceHash)->where('cae', '55110')->update(['cae' => '01', 'activity' => 'HOTÉIS COM RESTAURANTE']);
$a = $this->aggregateSnapshot($id, ['classification_key' => 'ebau:979']);
$this->assertSame([], $a['groups']);
$v = $this->getJson('/investimentos/'.$id)->json('case.version');
$this->postJson('/investimentos/'.$id.'/contexto/oferta-agregada', ['version' => $v, 'operation_key' => (string) Str::uuid(), 'classification_key' => 'ebau:99999999'])->assertUnprocessable();
$this->postJson('/investimentos/'.$id.'/contexto/oferta-agregada', ['version' => $v, 'operation_key' => (string) Str::uuid(), 'classification_key' => 'ebau:979', 'activity_id' => $this->activities['hotel']])->assertUnprocessable();
$this->actingAs(User::factory()->create())->postJson('/investimentos/'.$id.'/contexto/oferta-agregada', ['version' => $v, 'operation_key' => (string) Str::uuid(), 'classification_key' => 'ebau:979'])->assertForbidden();
}
public function test_previously_accepted_tourism_scope_is_accommodation_without_inventing_a_specific_subtype(): void
{
$source = 'f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c';
$this->assertFalse(DB::table('proactive_supply_sources')->where('sha256', $source)->exists(), 'Only isolated synthetic source allowed');
DB::table('proactive_supply_sources')->insert(['sha256' => $source, 'name' => 'SYNTHETIC accepted tourism contract', 'period' => '2024', 'limitations' => 'SYNTHETIC', 'receipt' => json_encode(['candidate' => 'a022ed9848b41ba342b4209414c2bbf2ebd0a7a3', 'access' => 'internal', 'synthetic' => true], JSON_THROW_ON_ERROR), 'imported_at' => now()]);
for ($i = 0; $i < 6; $i++) {
$row = $this->observation($source, '55', 'tourism-broad-'.$i, $this->geo['large']);
DB::table('proactive_supply_observations')->where('id', $row)->update(['cae' => '', 'activity' => 'Alojamento (âmbito do exemplar; categoria por conferir)']);
}
$id = $this->dossier('hotel');
$a = $this->aggregateSnapshot($id, ['classification_key' => 'ebau:158']);
$this->assertSame('ALOJAMENTO', $a['groups'][0]['label']);
$this->assertSame('5–9', $a['groups'][0]['entities_band']);
$this->assertStringNotContainsString('HOTÉIS COM RESTAURANTE', $a['groups'][0]['label']);
}
public function test_unresolved_observation_keys_conflicts_duplicates_and_unknown_import_contracts_do_not_form_a_publishable_cohort(): void