302 lines
11 KiB
JavaScript
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,
|
|
}),
|
|
);
|