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, }), );