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 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]);
+47 -29
View File
@@ -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<string, EpaySolutionDoc>();
// Decode HTML entities — ePay embeds JSON with &quot; encoding
const decoded = html
@@ -550,17 +552,20 @@ export class EpayClient {
.replace(/&#x21B;/g, "ț")
.replace(/&#x219;/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(
+17 -8
View File
@@ -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", {
@@ -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<string, EpaySolutionDoc>;
};
/* ── Domain Types ────────────────────────────────────────────────── */