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) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 09:29:11 +02:00
parent a59d9bc923
commit 0c94af75d3
4 changed files with 84 additions and 47 deletions
@@ -606,16 +606,24 @@ export function ParcelSyncModule() {
} }
const isDigit = /^\d+$/.test(raw); const isDigit = /^\d+$/.test(raw);
const query = normalizeText(raw); const query = normalizeText(raw);
const results = uatData // Filter and sort: UAT name matches first, then county-only matches
.filter((item) => { const nameMatches: typeof uatData = [];
if (isDigit) return item.siruta.startsWith(raw); const countyOnlyMatches: typeof uatData = [];
// Match UAT name or county name
if (normalizeText(item.name).includes(query)) return true; for (const item of uatData) {
if (item.county && normalizeText(item.county).includes(query)) if (isDigit) {
return true; if (item.siruta.startsWith(raw)) nameMatches.push(item);
return false; } else {
}) const nameMatch = normalizeText(item.name).includes(query);
.slice(0, 12); 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); setUatResults(results);
}, [uatQuery, uatData]); }, [uatQuery, uatData]);
+47 -29
View File
@@ -540,6 +540,8 @@ export class EpayClient {
else if (html.includes("Plata refuzata")) status = "Plata refuzata"; else if (html.includes("Plata refuzata")) status = "Plata refuzata";
const documents: EpaySolutionDoc[] = []; const documents: EpaySolutionDoc[] = [];
// Map CF number → document (for correct batch matching)
const documentsByCadastral = new Map<string, EpaySolutionDoc>();
// Decode HTML entities — ePay embeds JSON with &quot; encoding // Decode HTML entities — ePay embeds JSON with &quot; encoding
const decoded = html const decoded = html
@@ -550,17 +552,20 @@ export class EpayClient {
.replace(/&#x21B;/g, "ț") .replace(/&#x21B;/g, "ț")
.replace(/&#x219;/g, "ș"); .replace(/&#x219;/g, "ș");
// Parse solutii JSON from decoded HTML // Parse metadateCereri blocks to match CF → solutii (document)
// Format: "solutii":[{"idDocument":47301767,"nome":"Extras_Informare_65346.pdf",...}] // Each metadateCerere has: metadate.CF.stringValues[0] + solutii[{idDocument,...}]
const solutiiPattern = /"solutii"\s*:\s*\[(\{[^[\]]*\})\]/g; // Pattern: find each block with both CF and solutii
let solutiiMatch; const metadataPattern =
while ((solutiiMatch = solutiiPattern.exec(decoded)) !== null) { /"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 { try {
const arr = JSON.parse(`[${solutiiMatch[1]}]`); const solutii = JSON.parse(`[${metaMatch[2]}]`);
for (const s of arr) { for (const s of solutii) {
if (s?.idDocument && s?.idTipDocument === null) { if (s?.idDocument && s?.idTipDocument === null) {
// idTipDocument=null means it's a soluție (CF extract), not a chitanță (idTipDocument=1) const doc: EpaySolutionDoc = {
documents.push({
idDocument: s.idDocument, idDocument: s.idDocument,
idTipDocument: null, idTipDocument: null,
nume: s.nume ?? "", nume: s.nume ?? "",
@@ -573,31 +578,40 @@ export class EpayClient {
valabilNelimitat: s.valabilNelimitat ?? true, valabilNelimitat: s.valabilNelimitat ?? true,
zileValabilitateDownload: s.zileValabilitateDownload ?? -1, zileValabilitateDownload: s.zileValabilitateDownload ?? -1,
transactionId: s.transactionId ?? 0, transactionId: s.transactionId ?? 0,
}); };
documents.push(doc);
if (cfNumber) documentsByCadastral.set(cfNumber, doc);
} }
} }
} catch { /* parse failed */ } } catch { /* parse failed */ }
} }
// Fallback: extract idDocument directly from decoded HTML // Fallback: simpler solutii pattern (without CF matching)
if (documents.length === 0) { 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; const solutiiPattern = /"solutii"\s*:\s*\[(\{[^[\]]*\})\]/g;
let idMatch; let solutiiMatch;
while ((idMatch = idPattern.exec(decoded)) !== null) { while ((solutiiMatch = solutiiPattern.exec(decoded)) !== null) {
documents.push({ try {
idDocument: parseInt(idMatch[1] ?? "0", 10), const arr = JSON.parse(`[${solutiiMatch[1]}]`);
idTipDocument: null, for (const s of arr) {
nume: idMatch[2] ?? "", if (s?.idDocument && s?.idTipDocument === null) {
numar: null, documents.push({
serie: null, idDocument: s.idDocument,
dataDocument: idMatch[3] ?? "", idTipDocument: null,
contentType: "application/pdf", nume: s.nume ?? "",
linkDownload: "", numar: s.numar ?? null,
downloadValabil: true, serie: s.serie ?? null,
valabilNelimitat: true, dataDocument: s.dataDocument ?? "",
zileValabilitateDownload: -1, contentType: s.contentType ?? "application/pdf",
transactionId: 0, 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`); 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( async pollUntilComplete(
+17 -8
View File
@@ -355,10 +355,8 @@ async function processBatch(
`[epay-queue] Order ${orderId}: ${downloadableDocs.length} documents for ${validItems.length} items`, `[epay-queue] Order ${orderId}: ${downloadableDocs.length} documents for ${validItems.length} items`,
); );
// Try to match documents to items by cadastral number in filename // Match documents to items by CF number (from documentsByCadastral)
// ePay filenames look like "Extras_Informare_65297.pdf" (the number is an internal ID, not nrCadastral) // This is the CORRECT way — ePay returns docs in its own order, not ours
// If we have exactly as many docs as items, assign in order.
// Otherwise, download all and try best-effort matching.
if (downloadableDocs.length === 0) { if (downloadableDocs.length === 0) {
for (const item of validItems) { for (const item of validItems) {
@@ -370,12 +368,23 @@ async function processBatch(
return orderId; 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++) { for (let i = 0; i < validItems.length; i++) {
const item = validItems[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) { if (!doc) {
await updateStatus(item.extractId, "failed", { await updateStatus(item.extractId, "failed", {
@@ -67,6 +67,8 @@ export type EpayOrderStatus = {
orderId: string; orderId: string;
status: string; // "Receptionata" | "In curs de procesare" | "Finalizata" | "Anulata" status: string; // "Receptionata" | "In curs de procesare" | "Finalizata" | "Anulata"
documents: EpaySolutionDoc[]; documents: EpaySolutionDoc[];
/** CF number → document mapping (for correct batch assignment) */
documentsByCadastral: Map<string, EpaySolutionDoc>;
}; };
/* ── Domain Types ────────────────────────────────────────────────── */ /* ── Domain Types ────────────────────────────────────────────────── */