fix(epay): paginate ShowOrderDetails — orders >5 items only exposed first page

ePay paginates order documents 5/page (&navDir=<page>, 'Total items: N').
getOrderStatus only parsed page 1, so a 15-item order surfaced 5 docs:
5 parcels CF-matched correctly, 5 got WRONG PDFs via the index fallback,
5 failed with 'Document lipsă' (2026-06-04, order 10009605).

- parseOrderPage(): per-page CF+solutii extraction, zipped per page
- getOrderStatus(): walks all pages, dedupes by idDocument, stops on
  empty page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-06-04 18:44:17 +03:00
parent 2fed59dad6
commit f7f7c59d17
+68 -29
View File
@@ -559,23 +559,15 @@ export class EpayClient {
/* ── Order Status & Polling ────────────────────────────────── */ /* ── Order Status & Polling ────────────────────────────────── */
async getOrderStatus(orderId: string): Promise<EpayOrderStatus> { /**
const response = await this.client.get( * Parse one ShowOrderDetails page: extract CF numbers + solutii docs and
`${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}`, * zip them by position (each metadateCereri block carries both, in the
{ timeout: DEFAULT_TIMEOUT_MS }, * same order WITHIN a page — zipping must therefore happen per page).
); */
const html = String(response.data ?? ""); private parseOrderPage(html: string): {
cfNumbers: string[];
let status = "Receptionata"; solutii: EpaySolutionDoc[];
if (html.includes("Finalizata")) status = "Finalizata"; } {
else if (html.includes("In curs de procesare")) status = "In curs de procesare";
else if (html.includes("Anulata")) status = "Anulata";
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 // Decode HTML entities — ePay embeds JSON with &quot; encoding
const decoded = html const decoded = html
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
@@ -585,10 +577,6 @@ export class EpayClient {
.replace(/&#x21B;/g, "ț") .replace(/&#x21B;/g, "ț")
.replace(/&#x219;/g, "ș"); .replace(/&#x219;/g, "ș");
// Strategy: find CF numbers and solutii separately, then match by position.
// In the HTML, each metadateCereri block has both CF.stringValues and solutii
// in the same order. We find them independently and zip them.
// 1. Find all CF numbers: "CF":{"name":"CF",...,"stringValues":["345295"]} // 1. Find all CF numbers: "CF":{"name":"CF",...,"stringValues":["345295"]}
const cfPattern = /"CF"\s*:\s*\{[^}]*"stringValues"\s*:\s*\["(\d+)"\]/g; const cfPattern = /"CF"\s*:\s*\{[^}]*"stringValues"\s*:\s*\["(\d+)"\]/g;
const cfNumbers: string[] = []; const cfNumbers: string[] = [];
@@ -599,14 +587,14 @@ export class EpayClient {
// 2. Find all solutii blocks (CF extracts only, idTipDocument=null) // 2. Find all solutii blocks (CF extracts only, idTipDocument=null)
const solutiiPattern = /"solutii"\s*:\s*\[(\{[^[\]]*\})\]/g; const solutiiPattern = /"solutii"\s*:\s*\[(\{[^[\]]*\})\]/g;
const allSolutii: EpaySolutionDoc[] = []; const solutii: EpaySolutionDoc[] = [];
let solutiiMatch; let solutiiMatch;
while ((solutiiMatch = solutiiPattern.exec(decoded)) !== null) { while ((solutiiMatch = solutiiPattern.exec(decoded)) !== null) {
try { try {
const arr = JSON.parse(`[${solutiiMatch[1]}]`); const arr = JSON.parse(`[${solutiiMatch[1]}]`);
for (const s of arr) { for (const s of arr) {
if (s?.idDocument && s?.idTipDocument === null) { if (s?.idDocument && s?.idTipDocument === null) {
allSolutii.push({ solutii.push({
idDocument: s.idDocument, idDocument: s.idDocument,
idTipDocument: null, idTipDocument: null,
nume: s.nume ?? "", nume: s.nume ?? "",
@@ -625,18 +613,69 @@ export class EpayClient {
} catch { /* parse failed */ } } catch { /* parse failed */ }
} }
// 3. Zip CF numbers with solutii — they appear in the same order return { cfNumbers, solutii };
documents.push(...allSolutii); }
for (let i = 0; i < Math.min(cfNumbers.length, allSolutii.length); i++) {
async getOrderStatus(orderId: string): Promise<EpayOrderStatus> {
const response = await this.client.get(
`${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}`,
{ timeout: DEFAULT_TIMEOUT_MS },
);
const html = String(response.data ?? "");
let status = "Receptionata";
if (html.includes("Finalizata")) status = "Finalizata";
else if (html.includes("In curs de procesare")) status = "In curs de procesare";
else if (html.includes("Anulata")) status = "Anulata";
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>();
const seenDocIds = new Set<number>();
const collect = (pageHtml: string): number => {
const { cfNumbers, solutii } = this.parseOrderPage(pageHtml);
let added = 0;
for (const doc of solutii) {
if (seenDocIds.has(doc.idDocument)) continue;
seenDocIds.add(doc.idDocument);
documents.push(doc);
added++;
}
for (let i = 0; i < Math.min(cfNumbers.length, solutii.length); i++) {
const cf = cfNumbers[i]; const cf = cfNumbers[i];
const doc = allSolutii[i]; const doc = solutii[i];
if (cf && doc) { if (cf && doc && !documentsByCadastral.has(cf)) {
documentsByCadastral.set(cf, doc); documentsByCadastral.set(cf, doc);
} }
} }
return added;
};
collect(html);
// ShowOrderDetails paginates the requests (5/page, "Total items: N",
// page selected via &navDir=<page>; page 1 == no param). Without this,
// a 15-item batch only ever saw its first 5 documents (2026-06-04,
// order 10009605).
const totalMatch = html.match(/Total items:\s*(?:<[^>]*>)?\s*(\d+)/i);
const totalItems = totalMatch ? parseInt(totalMatch[1] ?? "0", 10) : 0;
const perPage = Math.max(documents.length, 1);
if (totalItems > documents.length) {
const pages = Math.min(Math.ceil(totalItems / perPage), 20);
for (let page = 2; page <= pages; page++) {
const pageResponse = await this.client.get(
`${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}&navDir=${page}`,
{ timeout: DEFAULT_TIMEOUT_MS },
);
const added = collect(String(pageResponse.data ?? ""));
if (added === 0) break; // defensive: stop if a page yields nothing new
}
}
console.log( console.log(
`[epay] Order ${orderId}: CFs=[${cfNumbers.join(",")}], docs=${allSolutii.length}, matched=${documentsByCadastral.size}`, `[epay] Order ${orderId}: total=${totalItems || "?"}, docs=${documents.length}, matched=${documentsByCadastral.size}`,
); );
if (documents.length === 0 && status === "Finalizata") { if (documents.length === 0 && status === "Finalizata") {