From 0c94af75d373c997e40989cd7f395482f341d1a0 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 23 Mar 2026 09:29:11 +0200 Subject: [PATCH] fix(ancpi): correct PDF-to-parcel matching + UAT search priority Critical fix: batch order documents are now matched by CF number from parsed metadateCereri (documentsByCadastral), not by index. Prevents PDF content mismatch when ePay returns docs in different order. UAT search: name matches shown first, county-only matches after. Typing "cluj" now shows CLUJ-NAPOCA before county "Cluj" matches. Cleaned MinIO + DB of incorrectly mapped old test data. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/parcel-sync-module.tsx | 28 ++++--- .../parcel-sync/services/epay-client.ts | 76 ++++++++++++------- .../parcel-sync/services/epay-queue.ts | 25 ++++-- .../parcel-sync/services/epay-types.ts | 2 + 4 files changed, 84 insertions(+), 47 deletions(-) diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 73a7d68..e487a97 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -606,16 +606,24 @@ export function ParcelSyncModule() { } const isDigit = /^\d+$/.test(raw); const query = normalizeText(raw); - const results = uatData - .filter((item) => { - if (isDigit) return item.siruta.startsWith(raw); - // Match UAT name or county name - if (normalizeText(item.name).includes(query)) return true; - if (item.county && normalizeText(item.county).includes(query)) - return true; - return false; - }) - .slice(0, 12); + // Filter and sort: UAT name matches first, then county-only matches + const nameMatches: typeof uatData = []; + const countyOnlyMatches: typeof uatData = []; + + for (const item of uatData) { + if (isDigit) { + if (item.siruta.startsWith(raw)) nameMatches.push(item); + } else { + const nameMatch = normalizeText(item.name).includes(query); + const countyMatch = + item.county && normalizeText(item.county).includes(query); + if (nameMatch) nameMatches.push(item); + else if (countyMatch) countyOnlyMatches.push(item); + } + } + + // UAT name matches first (priority), then county-only matches + const results = [...nameMatches, ...countyOnlyMatches].slice(0, 12); setUatResults(results); }, [uatQuery, uatData]); diff --git a/src/modules/parcel-sync/services/epay-client.ts b/src/modules/parcel-sync/services/epay-client.ts index 3104b2d..c4ec153 100644 --- a/src/modules/parcel-sync/services/epay-client.ts +++ b/src/modules/parcel-sync/services/epay-client.ts @@ -540,6 +540,8 @@ export class EpayClient { else if (html.includes("Plata refuzata")) status = "Plata refuzata"; const documents: EpaySolutionDoc[] = []; + // Map CF number → document (for correct batch matching) + const documentsByCadastral = new Map(); // Decode HTML entities — ePay embeds JSON with " encoding const decoded = html @@ -550,17 +552,20 @@ export class EpayClient { .replace(/ț/g, "ț") .replace(/ș/g, "ș"); - // Parse solutii JSON from decoded HTML - // Format: "solutii":[{"idDocument":47301767,"nome":"Extras_Informare_65346.pdf",...}] - const solutiiPattern = /"solutii"\s*:\s*\[(\{[^[\]]*\})\]/g; - let solutiiMatch; - while ((solutiiMatch = solutiiPattern.exec(decoded)) !== null) { + // Parse metadateCereri blocks to match CF → solutii (document) + // Each metadateCerere has: metadate.CF.stringValues[0] + solutii[{idDocument,...}] + // Pattern: find each block with both CF and solutii + const metadataPattern = + /"metadate"\s*:\s*\{[^}]*"CF"\s*:\s*\{[^}]*"stringValues"\s*:\s*\["(\d+)"\][^]*?"solutii"\s*:\s*\[(\{[^[\]]*\})\]/g; + + let metaMatch; + while ((metaMatch = metadataPattern.exec(decoded)) !== null) { + const cfNumber = metaMatch[1] ?? ""; try { - const arr = JSON.parse(`[${solutiiMatch[1]}]`); - for (const s of arr) { + const solutii = JSON.parse(`[${metaMatch[2]}]`); + for (const s of solutii) { if (s?.idDocument && s?.idTipDocument === null) { - // idTipDocument=null means it's a soluție (CF extract), not a chitanță (idTipDocument=1) - documents.push({ + const doc: EpaySolutionDoc = { idDocument: s.idDocument, idTipDocument: null, nume: s.nume ?? "", @@ -573,31 +578,40 @@ export class EpayClient { valabilNelimitat: s.valabilNelimitat ?? true, zileValabilitateDownload: s.zileValabilitateDownload ?? -1, transactionId: s.transactionId ?? 0, - }); + }; + documents.push(doc); + if (cfNumber) documentsByCadastral.set(cfNumber, doc); } } } catch { /* parse failed */ } } - // Fallback: extract idDocument directly from decoded HTML + // Fallback: simpler solutii pattern (without CF matching) if (documents.length === 0) { - const idPattern = /"idDocument"\s*:\s*(\d+)\s*,\s*"idTipDocument"\s*:\s*null\s*,\s*"nume"\s*:\s*"([^"]+)"\s*,.*?"dataDocument"\s*:\s*"([^"]+)"/g; - let idMatch; - while ((idMatch = idPattern.exec(decoded)) !== null) { - documents.push({ - idDocument: parseInt(idMatch[1] ?? "0", 10), - idTipDocument: null, - nume: idMatch[2] ?? "", - numar: null, - serie: null, - dataDocument: idMatch[3] ?? "", - contentType: "application/pdf", - linkDownload: "", - downloadValabil: true, - valabilNelimitat: true, - zileValabilitateDownload: -1, - transactionId: 0, - }); + const solutiiPattern = /"solutii"\s*:\s*\[(\{[^[\]]*\})\]/g; + let solutiiMatch; + while ((solutiiMatch = solutiiPattern.exec(decoded)) !== null) { + try { + const arr = JSON.parse(`[${solutiiMatch[1]}]`); + for (const s of arr) { + if (s?.idDocument && s?.idTipDocument === null) { + documents.push({ + idDocument: s.idDocument, + idTipDocument: null, + nume: s.nume ?? "", + numar: s.numar ?? null, + serie: s.serie ?? null, + dataDocument: s.dataDocument ?? "", + contentType: s.contentType ?? "application/pdf", + linkDownload: s.linkDownload ?? "", + downloadValabil: s.downloadValabil ?? true, + valabilNelimitat: s.valabilNelimitat ?? true, + zileValabilitateDownload: s.zileValabilitateDownload ?? -1, + transactionId: s.transactionId ?? 0, + }); + } + } + } catch { /* parse failed */ } } } @@ -605,7 +619,11 @@ export class EpayClient { console.warn(`[epay] Order ${orderId}: Finalizata but no documents found`); } - return { orderId, status, documents }; + console.log( + `[epay] Order ${orderId}: ${documents.length} docs, ${documentsByCadastral.size} matched by CF`, + ); + + return { orderId, status, documents, documentsByCadastral }; } async pollUntilComplete( diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts index ca98c97..2390e23 100644 --- a/src/modules/parcel-sync/services/epay-queue.ts +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -355,10 +355,8 @@ async function processBatch( `[epay-queue] Order ${orderId}: ${downloadableDocs.length} documents for ${validItems.length} items`, ); - // Try to match documents to items by cadastral number in filename - // ePay filenames look like "Extras_Informare_65297.pdf" (the number is an internal ID, not nrCadastral) - // If we have exactly as many docs as items, assign in order. - // Otherwise, download all and try best-effort matching. + // Match documents to items by CF number (from documentsByCadastral) + // This is the CORRECT way — ePay returns docs in its own order, not ours if (downloadableDocs.length === 0) { for (const item of validItems) { @@ -370,12 +368,23 @@ async function processBatch( return orderId; } - // Simple matching strategy: - // If docs.length === items.length, assign 1:1 in order - // Otherwise, assign first doc to first item, etc., and leftover items get "failed" for (let i = 0; i < validItems.length; i++) { const item = validItems[i]!; - const doc = downloadableDocs[i]; + const nrCF = item.input.nrCF ?? item.input.nrCadastral; + + // Try CF-based matching first (correct for batch orders) + let doc = finalStatus.documentsByCadastral.get(nrCF); + // Also try nrCadastral if different from nrCF + if (!doc && item.input.nrCadastral !== nrCF) { + doc = finalStatus.documentsByCadastral.get(item.input.nrCadastral); + } + // Last resort: fall back to index matching + if (!doc) { + doc = downloadableDocs[i]; + console.warn( + `[epay-queue] Could not match by CF for ${item.input.nrCadastral}, using index ${i}`, + ); + } if (!doc) { await updateStatus(item.extractId, "failed", { diff --git a/src/modules/parcel-sync/services/epay-types.ts b/src/modules/parcel-sync/services/epay-types.ts index c3abfc2..6f93780 100644 --- a/src/modules/parcel-sync/services/epay-types.ts +++ b/src/modules/parcel-sync/services/epay-types.ts @@ -67,6 +67,8 @@ export type EpayOrderStatus = { orderId: string; status: string; // "Receptionata" | "In curs de procesare" | "Finalizata" | "Anulata" documents: EpaySolutionDoc[]; + /** CF number → document mapping (for correct batch assignment) */ + documentsByCadastral: Map; }; /* ── Domain Types ────────────────────────────────────────────────── */