Files
Investidor-V2/tools/prepare-investment-relations.mjs
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

302 lines
11 KiB
JavaScript

import fs from 'node:fs';
import crypto from 'node:crypto';
const dir = 'storage/app/private/anchor-project-reconciliation/';
const inventory = JSON.parse(
fs.readFileSync(
dir +
fs
.readdirSync(dir)
.filter((f) => f.startsWith('inventory'))
.sort()
.at(-1),
'utf8',
),
);
const texts = JSON.parse(fs.readFileSync(dir + 'source-texts.json', 'utf8'));
const normalize = (s) =>
s
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.trim();
const provincePlan = {
A03: [
['Nampula', 'secondary'],
['Cabo Delgado', 'secondary'],
['Niassa', 'secondary'],
['Tete', 'secondary'],
['Zambezia', 'impact'],
],
A04: [['Nampula', 'primary']],
A05: [
['Nampula', 'secondary'],
['Cabo Delgado', 'secondary'],
],
A06: [
['Tete', 'secondary'],
['Manica', 'secondary'],
['Zambezia', 'secondary'],
['Sofala', 'secondary'],
],
A07: [
['Sofala', 'primary'],
['Manica', 'secondary'],
['Tete', 'secondary'],
],
A08: [['Zambezia', 'primary']],
A09: [['Zambezia', 'primary']],
A10: [['Tete', 'primary']],
A11: [
['Tete', 'secondary'],
['Zambezia', 'secondary'],
],
A12: [['Zambezia', 'primary']],
A13: [['Inhambane', 'primary']],
A14: [['Gaza', 'primary']],
A15: [['Maputo Província', 'primary']],
};
const plan = {
schema: 1,
target: inventory.target,
inventory_file: fs
.readdirSync(dir)
.filter((f) => f.startsWith('inventory'))
.sort()
.at(-1),
created_at: new Date().toISOString(),
locations: [],
memberships: [],
prepared_opportunities: [],
pending: [],
};
for (const project of inventory.projects.filter((p) =>
/^A\d{2}$/.test(p.code),
)) {
const dossier = inventory.dossiers.find(
(d) => d.project_id === project.id && !d.deleted_at,
);
const content = JSON.parse(dossier.content);
for (const [name, role] of provincePlan[project.code] ?? []) {
const nodes = inventory.locations.filter(
(l) =>
l.location_type === 'province' &&
!l.deleted_at &&
normalize(l.name) === normalize(name),
);
if (nodes.length !== 1) {
plan.pending.push({
code: project.code,
reason: 'Identidade territorial ambígua',
name,
});
continue;
}
const location = nodes[0];
plan.locations.push({
project_id: project.id,
code: project.code,
dossier_id: dossier.id,
dossier_version: dossier.version_no,
geo_location_id: location.id,
name: location.name,
role,
source_reference: dossier.source,
source_quote: content.location,
reason:
role === 'impact'
? 'Referência parcial ao Norte da Zambézia; não representa cobertura integral da província'
: 'Província explicitamente identificada na localização do dossier autorizado',
expected_project_version: project.version_no,
data_classification:
dossier.publication_status === 'published'
? 'public'
: 'internal',
existing: inventory.project_locations.some(
(l) =>
!l.deleted_at &&
l.project_id === project.id &&
l.geo_location_id === location.id &&
l.role === role,
),
});
}
if (['A01', 'A02'].includes(project.code))
plan.pending.push({
code: project.code,
reason: 'Âmbito nacional sem implantação individual ou traçado documentado; não atribuir um ponto único.',
});
plan.pending.push({
code: project.code,
reason: 'Coordenadas, posto administrativo e localidade sem fonte exacta. Cidade/zona não convertida automaticamente em distrito.',
});
}
// Districts expressly called districts in the source; city names and ambiguous settlements remain text.
for (const [code, province, districts, role] of [
['A15', 'Maputo Província', ['Boane', 'Moamba', 'Namaacha'], 'impact'],
['A11', 'Zambezia', ['Nicoadala'], 'secondary'],
]) {
const p = inventory.projects.find((p) => p.code === code),
_d = inventory.dossiers.find((d) => d.project_id === p.id),
base = plan.locations.find(
(l) => l.code === code && normalize(l.name) === normalize(province),
);
for (const name of districts) {
const nodes = inventory.locations.filter(
(l) =>
l.location_type === 'district' &&
l.parent_id === base.geo_location_id &&
!l.deleted_at &&
normalize(l.name) === normalize(name),
);
if (nodes.length !== 1) {
plan.pending.push({
code,
reason: 'Distrito sem correspondência exacta e única',
name,
});
continue;
}
plan.locations.push({
...base,
geo_location_id: nodes[0].id,
name: nodes[0].name,
role,
reason: 'Distrito explicitamente identificado na fonte; sem inferir posto, localidade ou coordenadas.',
existing: inventory.project_locations.some(
(l) =>
!l.deleted_at &&
l.project_id === p.id &&
l.geo_location_id === nodes[0].id &&
l.role === role,
),
});
}
}
for (const cluster of inventory.clusters) {
const dossier = inventory.dossiers.find(
(d) => d.cluster_id === cluster.id && !d.deleted_at,
),
content = JSON.parse(dossier.content);
for (const code of new Set(content.anchors.match(/\bA\d{2}\b/g) ?? [])) {
const project = inventory.projects.find(
(p) => p.code === code && !p.deleted_at,
);
if (!project) {
plan.pending.push({
code: cluster.code,
project: code,
reason: 'Projecto citado sem cadastro',
});
continue;
}
const existing = inventory.cluster_projects.find(
(l) =>
l.project_id === project.id &&
l.cluster_id === cluster.id &&
!l.deleted_at,
);
plan.memberships.push({
cluster_id: cluster.id,
cluster_code: cluster.code,
project_id: project.id,
project_code: code,
role: 'anchor',
existing_id: existing?.id ?? null,
existing_version: existing?.version_no ?? 0,
dossier_id: dossier.id,
dossier_version: dossier.version_no,
source_reference: dossier.source,
source_quote: content.anchors,
reason: 'Código exacto do projecto citado no dossier do cluster; relação de planeamento, sem confirmar pertença jurídica',
action: existing ? 'present_existing' : 'insert_missing',
});
}
plan.pending.push({
code: cluster.code,
reason: 'Área própria de abrangência não individualizada na fonte; locais dos membros não definem o perímetro do cluster.',
});
if (
!inventory.value_chains.some(
(v) => v.cluster_id === cluster.id && !v.deleted_at,
)
)
plan.pending.push({
code: cluster.code,
reason: 'Sem elos cadastrados em value_chains. Actividades e lacunas do dossier permanecem consultáveis; não foram convertidas em cadeias confirmadas.',
});
}
const source = texts.find((t) => t.path.endsWith('projectos.docx'));
for (const [index, table] of source.tables.entries()) {
if (!table[0].includes('Localização')) continue;
const anchor = table[1]?.[0]?.trim();
const conflict = index < 3; // caption/project identifier conflict documented in original, never silently renumbered.
for (const [rowIndex, row] of table.entries()) {
if (rowIndex < 2 || !row[0]?.trim()) continue;
const original = row[0].trim(),
project = inventory.projects.find((p) => p.code === anchor),
ref = original;
const existing = inventory.opportunities.find(
(o) => o.reference === ref,
);
plan.prepared_opportunities.push({
reference: ref,
source_identifier: row[0],
title: row[1],
summary: row[3],
description: row
.map((cell, i) => `${table[0][i]}: ${cell}`)
.join('\n'),
location_text: row[4],
cost_text: row[5],
horizon_text: row[6],
source_reference: `${source.path}; SHA256 ${source.sha256}; tabela ${index + 1}, linha ${rowIndex + 1}`,
source_sha256: source.sha256,
source_table: index + 1,
source_row: rowIndex + 1,
source_cells: row,
status: 'draft',
project_id: conflict ? null : (project?.id ?? null),
project_code: conflict ? null : anchor,
existing_id: existing?.id ?? null,
identity_status: conflict
? 'conflict_caption_anchor_code'
: 'explicit_source_code',
unknown: [
'sector_id',
'promotor confirmado',
'maturidade',
'coordenadas',
'títulos e disponibilidade',
'interpretação monetária',
],
reason: conflict
? 'Código da âncora e legenda da tabela incompatíveis com os dossiers; não associar nem renumerar'
: 'Componente identificado por código exacto na tabela da âncora; preparado sem publicar, sem associação automática aos clusters',
});
}
}
plan.pending.push({
code: 'opportunity_projects',
reason: 'As seis oportunidades existentes não têm código documental que as associe às âncoras. Nenhuma associação inferida por nome ou sector.',
});
const data = JSON.stringify(plan, null, 2);
fs.writeFileSync(dir + 'preview.json', data);
fs.writeFileSync(
dir + 'preview.sha256',
crypto.createHash('sha256').update(data).digest('hex'),
);
console.log(
JSON.stringify({
locations: plan.locations.length,
projectsWithLocations: new Set(plan.locations.map((l) => l.code)).size,
memberships: plan.memberships.length,
existingMemberships: plan.memberships.filter((l) => l.existing_id)
.length,
prepared: plan.prepared_opportunities.length,
identityConflicts: plan.prepared_opportunities.filter((o) =>
o.identity_status.startsWith('conflict'),
).length,
pending: plan.pending.length,
}),
);