feat(ancpi): batch ordering + download existing orders
Major rewrite: - Queue now processes batches: addToCart×N → saveMetadata×N → ONE submitOrder → poll → download ALL documents → store in MinIO - Removed unique constraint on orderId (shared across batch items) - Added step=download to test endpoint: downloads PDFs from 5 existing orders (9685480-9685484) and stores in MinIO - step=order now uses enqueueBatch for 2 test parcels (61904, 309952) as ONE ePay order instead of separate orders Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,7 +120,7 @@ model RegistryAudit {
|
|||||||
|
|
||||||
model CfExtract {
|
model CfExtract {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orderId String? @unique // ePay orderId
|
orderId String? // ePay orderId (shared across batch items)
|
||||||
basketRowId Int? // ePay cart item ID
|
basketRowId Int? // ePay cart item ID
|
||||||
nrCadastral String // cadastral number
|
nrCadastral String // cadastral number
|
||||||
nrCF String? // CF number if different
|
nrCF String? // CF number if different
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getEpayCredentials } from "@/modules/parcel-sync/services/epay-session-store";
|
import { getEpayCredentials } from "@/modules/parcel-sync/services/epay-session-store";
|
||||||
import {
|
import {
|
||||||
enqueueOrder,
|
enqueueOrder,
|
||||||
enqueueBulk,
|
enqueueBatch,
|
||||||
} from "@/modules/parcel-sync/services/epay-queue";
|
} from "@/modules/parcel-sync/services/epay-queue";
|
||||||
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types";
|
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types";
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = await enqueueBulk(parcels);
|
const ids = await enqueueBatch(parcels);
|
||||||
const orders = ids.map((id, i) => ({
|
const orders = ids.map((id, i) => ({
|
||||||
id,
|
id,
|
||||||
nrCadastral: parcels[i]?.nrCadastral ?? "",
|
nrCadastral: parcels[i]?.nrCadastral ?? "",
|
||||||
|
|||||||
+244
-30
@@ -4,13 +4,15 @@ import {
|
|||||||
createEpaySession,
|
createEpaySession,
|
||||||
getEpayCredentials,
|
getEpayCredentials,
|
||||||
} from "@/modules/parcel-sync/services/epay-session-store";
|
} from "@/modules/parcel-sync/services/epay-session-store";
|
||||||
import { enqueueOrder } from "@/modules/parcel-sync/services/epay-queue";
|
import { enqueueBatch } from "@/modules/parcel-sync/services/epay-queue";
|
||||||
|
import { storeCfExtract } from "@/modules/parcel-sync/services/epay-storage";
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ancpi/test?step=login|uats|order
|
* GET /api/ancpi/test?step=login|uats|order|download
|
||||||
*
|
*
|
||||||
* ePay internal county IDs = eTerra WORKSPACE_IDs.
|
* ePay internal county IDs = eTerra WORKSPACE_IDs.
|
||||||
* ePay UAT IDs = SIRUTA codes.
|
* ePay UAT IDs = SIRUTA codes.
|
||||||
@@ -67,8 +69,232 @@ export async function GET(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── order ── (USES 3 CREDITS!)
|
// ── download ── Download PDFs from 5 existing orders
|
||||||
// Uses WORKSPACE_ID as county ID, SIRUTA as UAT ID — zero discovery
|
if (step === "download") {
|
||||||
|
const client = await EpayClient.create(username, password);
|
||||||
|
createEpaySession(username, password, await client.getCredits());
|
||||||
|
|
||||||
|
// Known orders from previous test
|
||||||
|
const orderIds = ["9685480", "9685481", "9685482", "9685483", "9685484"];
|
||||||
|
|
||||||
|
// Mapping: orderId → nrCadastral (5 orders for 3 parcels)
|
||||||
|
// Orders were for: 345295 (Cluj-Napoca), 63565 (Feleacu), 88089 (Florești)
|
||||||
|
// 5 orders for 3 parcels = some duplicates
|
||||||
|
const orderParcelMap: Record<
|
||||||
|
string,
|
||||||
|
{ nrCadastral: string; judetName: string; uatName: string }
|
||||||
|
> = {
|
||||||
|
"9685480": {
|
||||||
|
nrCadastral: "345295",
|
||||||
|
judetName: "CLUJ",
|
||||||
|
uatName: "Cluj-Napoca",
|
||||||
|
},
|
||||||
|
"9685481": {
|
||||||
|
nrCadastral: "63565",
|
||||||
|
judetName: "CLUJ",
|
||||||
|
uatName: "Feleacu",
|
||||||
|
},
|
||||||
|
"9685482": {
|
||||||
|
nrCadastral: "88089",
|
||||||
|
judetName: "CLUJ",
|
||||||
|
uatName: "Florești",
|
||||||
|
},
|
||||||
|
"9685483": {
|
||||||
|
nrCadastral: "345295",
|
||||||
|
judetName: "CLUJ",
|
||||||
|
uatName: "Cluj-Napoca",
|
||||||
|
},
|
||||||
|
"9685484": {
|
||||||
|
nrCadastral: "63565",
|
||||||
|
judetName: "CLUJ",
|
||||||
|
uatName: "Feleacu",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
orderId: string;
|
||||||
|
nrCadastral: string;
|
||||||
|
status: string;
|
||||||
|
documents: number;
|
||||||
|
downloaded: boolean;
|
||||||
|
minioPath?: string;
|
||||||
|
error?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const orderId of orderIds) {
|
||||||
|
const parcelInfo = orderParcelMap[orderId];
|
||||||
|
if (!parcelInfo) {
|
||||||
|
results.push({
|
||||||
|
orderId,
|
||||||
|
nrCadastral: "unknown",
|
||||||
|
status: "error",
|
||||||
|
documents: 0,
|
||||||
|
downloaded: false,
|
||||||
|
error: "No parcel mapping for orderId",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get order status and document info
|
||||||
|
const orderStatus = await client.getOrderStatus(orderId);
|
||||||
|
console.log(
|
||||||
|
`[ancpi-test] Order ${orderId}: status=${orderStatus.status}, docs=${orderStatus.documents.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderStatus.documents.length === 0) {
|
||||||
|
results.push({
|
||||||
|
orderId,
|
||||||
|
nrCadastral: parcelInfo.nrCadastral,
|
||||||
|
status: orderStatus.status,
|
||||||
|
documents: 0,
|
||||||
|
downloaded: false,
|
||||||
|
error: "No documents found in order",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find downloadable PDF document
|
||||||
|
const doc = orderStatus.documents.find(
|
||||||
|
(d) => d.downloadValabil && d.contentType === "application/pdf",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
results.push({
|
||||||
|
orderId,
|
||||||
|
nrCadastral: parcelInfo.nrCadastral,
|
||||||
|
status: orderStatus.status,
|
||||||
|
documents: orderStatus.documents.length,
|
||||||
|
downloaded: false,
|
||||||
|
error: "No downloadable PDF found",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the PDF
|
||||||
|
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
|
||||||
|
console.log(
|
||||||
|
`[ancpi-test] Downloaded doc ${doc.idDocument}: ${pdfBuffer.length} bytes`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store in MinIO
|
||||||
|
const { path, index } = await storeCfExtract(
|
||||||
|
pdfBuffer,
|
||||||
|
parcelInfo.nrCadastral,
|
||||||
|
{
|
||||||
|
"ancpi-order-id": orderId,
|
||||||
|
"nr-cadastral": parcelInfo.nrCadastral,
|
||||||
|
judet: parcelInfo.judetName,
|
||||||
|
uat: parcelInfo.uatName,
|
||||||
|
"data-document": doc.dataDocument ?? "",
|
||||||
|
stare: orderStatus.status,
|
||||||
|
produs: "EXI_ONLINE",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upsert CfExtract record — find by orderId or create
|
||||||
|
const documentDate = doc.dataDocument
|
||||||
|
? new Date(doc.dataDocument)
|
||||||
|
: new Date();
|
||||||
|
const expiresAt = new Date(documentDate);
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||||
|
|
||||||
|
// Try to find existing record by orderId
|
||||||
|
const existing = await prisma.cfExtract.findFirst({
|
||||||
|
where: { orderId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing record
|
||||||
|
await prisma.cfExtract.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
status: "completed",
|
||||||
|
epayStatus: orderStatus.status,
|
||||||
|
idDocument: doc.idDocument,
|
||||||
|
documentName: doc.nume,
|
||||||
|
documentDate,
|
||||||
|
minioPath: path,
|
||||||
|
minioIndex: index,
|
||||||
|
completedAt: new Date(),
|
||||||
|
expiresAt,
|
||||||
|
errorMessage: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new record
|
||||||
|
const maxVersion = await prisma.cfExtract.aggregate({
|
||||||
|
where: { nrCadastral: parcelInfo.nrCadastral },
|
||||||
|
_max: { version: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.cfExtract.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
nrCadastral: parcelInfo.nrCadastral,
|
||||||
|
nrCF: parcelInfo.nrCadastral,
|
||||||
|
judetIndex: 127,
|
||||||
|
judetName: parcelInfo.judetName,
|
||||||
|
uatId:
|
||||||
|
parcelInfo.uatName === "Cluj-Napoca"
|
||||||
|
? 54975
|
||||||
|
: parcelInfo.uatName === "Feleacu"
|
||||||
|
? 57582
|
||||||
|
: 57706,
|
||||||
|
uatName: parcelInfo.uatName,
|
||||||
|
status: "completed",
|
||||||
|
epayStatus: orderStatus.status,
|
||||||
|
idDocument: doc.idDocument,
|
||||||
|
documentName: doc.nume,
|
||||||
|
documentDate,
|
||||||
|
minioPath: path,
|
||||||
|
minioIndex: index,
|
||||||
|
completedAt: new Date(),
|
||||||
|
expiresAt,
|
||||||
|
version: (maxVersion._max.version ?? 0) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
orderId,
|
||||||
|
nrCadastral: parcelInfo.nrCadastral,
|
||||||
|
status: orderStatus.status,
|
||||||
|
documents: orderStatus.documents.length,
|
||||||
|
downloaded: true,
|
||||||
|
minioPath: path,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(
|
||||||
|
`[ancpi-test] Failed to process order ${orderId}:`,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
results.push({
|
||||||
|
orderId,
|
||||||
|
nrCadastral: parcelInfo.nrCadastral,
|
||||||
|
status: "error",
|
||||||
|
documents: 0,
|
||||||
|
downloaded: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
step: "download",
|
||||||
|
totalOrders: orderIds.length,
|
||||||
|
results,
|
||||||
|
summary: {
|
||||||
|
downloaded: results.filter((r) => r.downloaded).length,
|
||||||
|
failed: results.filter((r) => !r.downloaded).length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── order ── Batch order test (USES 2 CREDITS!)
|
||||||
|
// Uses enqueueBatch to create ONE ePay order for all parcels
|
||||||
if (step === "order") {
|
if (step === "order") {
|
||||||
if (!getEpayCredentials()) {
|
if (!getEpayCredentials()) {
|
||||||
createEpaySession(username, password, 0);
|
createEpaySession(username, password, 0);
|
||||||
@@ -78,24 +304,9 @@ export async function GET(req: Request) {
|
|||||||
const credits = await client.getCredits();
|
const credits = await client.getCredits();
|
||||||
createEpaySession(username, password, credits);
|
createEpaySession(username, password, credits);
|
||||||
|
|
||||||
if (credits < 3) {
|
|
||||||
return NextResponse.json({
|
|
||||||
error: `Doar ${credits} credite, trebuie 3.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// workspacePk=127 (CLUJ), SIRUTA codes as UAT IDs
|
|
||||||
const parcels = [
|
const parcels = [
|
||||||
{
|
{
|
||||||
nrCadastral: "345295",
|
nrCadastral: "61904",
|
||||||
siruta: "54975", // SIRUTA = ePay UAT ID
|
|
||||||
judetIndex: 127, // workspacePk = ePay county ID
|
|
||||||
judetName: "CLUJ",
|
|
||||||
uatId: 54975,
|
|
||||||
uatName: "Cluj-Napoca",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nrCadastral: "63565",
|
|
||||||
siruta: "57582",
|
siruta: "57582",
|
||||||
judetIndex: 127,
|
judetIndex: 127,
|
||||||
judetName: "CLUJ",
|
judetName: "CLUJ",
|
||||||
@@ -103,26 +314,29 @@ export async function GET(req: Request) {
|
|||||||
uatName: "Feleacu",
|
uatName: "Feleacu",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
nrCadastral: "88089",
|
nrCadastral: "309952",
|
||||||
siruta: "57706",
|
siruta: "54975",
|
||||||
judetIndex: 127,
|
judetIndex: 127,
|
||||||
judetName: "CLUJ",
|
judetName: "CLUJ",
|
||||||
uatId: 57706,
|
uatId: 54975,
|
||||||
uatName: "Florești",
|
uatName: "Cluj-Napoca",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ids: string[] = [];
|
if (credits < parcels.length) {
|
||||||
for (const p of parcels) {
|
return NextResponse.json({
|
||||||
const id = await enqueueOrder(p);
|
error: `Doar ${credits} credite, trebuie ${parcels.length}.`,
|
||||||
ids.push(id);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use enqueueBatch — ONE order for all parcels
|
||||||
|
const ids = await enqueueBatch(parcels);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
step: "order",
|
step: "order",
|
||||||
credits,
|
credits,
|
||||||
message: `Enqueued ${ids.length} orders (4 requests each, zero discovery).`,
|
message: `Enqueued batch of ${ids.length} parcels as ONE order.`,
|
||||||
orderIds: ids,
|
extractIds: ids,
|
||||||
parcels: parcels.map((p, i) => ({
|
parcels: parcels.map((p, i) => ({
|
||||||
nrCadastral: p.nrCadastral,
|
nrCadastral: p.nrCadastral,
|
||||||
uatName: p.uatName,
|
uatName: p.uatName,
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* ePay sequential order queue.
|
* ePay batch order queue.
|
||||||
*
|
*
|
||||||
* ePay has a GLOBAL cart per account — only one order can be processed
|
* ePay has a GLOBAL cart per account — only one order can be processed
|
||||||
* at a time. This queue ensures sequential execution.
|
* at a time. This queue ensures sequential execution.
|
||||||
*
|
*
|
||||||
* Flow per item:
|
* BATCH flow (correct — ONE order for N parcels):
|
||||||
* 1. Check credits
|
* 1. Check credits (need >= N)
|
||||||
* 2. Add to cart (prodId=14200)
|
* 2. For each parcel: addToCart → saveMetadata
|
||||||
* 3. Search estate on ePay
|
* 3. ONE EditCartSubmit → ONE orderId
|
||||||
* 4. Submit order (EditCartSubmit)
|
* 4. Poll that orderId until "Finalizata"
|
||||||
* 5. Poll status (15s × 40 = max 10 min)
|
* 5. Download ALL documents from that order
|
||||||
* 6. Download PDF
|
* 6. Store each in MinIO, update DB records
|
||||||
* 7. Store in MinIO
|
|
||||||
* 8. Update DB record
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { EpayClient } from "./epay-client";
|
import { EpayClient } from "./epay-client";
|
||||||
import { getEpayCredentials, updateEpayCredits } from "./epay-session-store";
|
import { getEpayCredentials, updateEpayCredits } from "./epay-session-store";
|
||||||
import { storeCfExtract } from "./epay-storage";
|
import { storeCfExtract } from "./epay-storage";
|
||||||
import { resolveEpayCountyIndex } from "./epay-counties";
|
|
||||||
import type { CfExtractCreateInput } from "./epay-types";
|
import type { CfExtractCreateInput } from "./epay-types";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -29,6 +26,11 @@ import type { CfExtractCreateInput } from "./epay-types";
|
|||||||
type QueueItem = {
|
type QueueItem = {
|
||||||
extractId: string; // CfExtract.id in DB
|
extractId: string; // CfExtract.id in DB
|
||||||
input: CfExtractCreateInput;
|
input: CfExtractCreateInput;
|
||||||
|
basketRowId?: number; // set during cart phase
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchJob = {
|
||||||
|
items: QueueItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -36,22 +38,41 @@ type QueueItem = {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const g = globalThis as {
|
const g = globalThis as {
|
||||||
__epayQueue?: QueueItem[];
|
__epayBatchQueue?: BatchJob[];
|
||||||
__epayQueueProcessing?: boolean;
|
__epayQueueProcessing?: boolean;
|
||||||
};
|
};
|
||||||
if (!g.__epayQueue) g.__epayQueue = [];
|
if (!g.__epayBatchQueue) g.__epayBatchQueue = [];
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Public API */
|
/* Public API */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueue a CF extract order. Creates a DB record and adds to the
|
* Enqueue a single CF extract order (backwards compatible).
|
||||||
* processing queue. Returns the CfExtract.id immediately.
|
* Creates a DB record, wraps as a batch of 1, and adds to the queue.
|
||||||
*/
|
*/
|
||||||
export async function enqueueOrder(
|
export async function enqueueOrder(
|
||||||
input: CfExtractCreateInput,
|
input: CfExtractCreateInput,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const ids = await enqueueBatch([input]);
|
||||||
|
const first = ids[0];
|
||||||
|
if (!first) throw new Error("enqueueBatch returned empty array");
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a batch of CF extract orders.
|
||||||
|
* Creates all DB records, then processes as ONE ePay order.
|
||||||
|
* Returns the CfExtract IDs immediately.
|
||||||
|
*/
|
||||||
|
export async function enqueueBatch(
|
||||||
|
inputs: CfExtractCreateInput[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (inputs.length === 0) return [];
|
||||||
|
|
||||||
|
const items: QueueItem[] = [];
|
||||||
|
|
||||||
|
for (const input of inputs) {
|
||||||
// Create DB record in "queued" status
|
// Create DB record in "queued" status
|
||||||
const record = await prisma.cfExtract.create({
|
const record = await prisma.cfExtract.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -65,7 +86,6 @@ export async function enqueueOrder(
|
|||||||
gisFeatureId: input.gisFeatureId,
|
gisFeatureId: input.gisFeatureId,
|
||||||
prodId: input.prodId ?? 14200,
|
prodId: input.prodId ?? 14200,
|
||||||
status: "queued",
|
status: "queued",
|
||||||
// Version: find max version for this cadastral number and increment
|
|
||||||
version:
|
version:
|
||||||
((
|
((
|
||||||
await prisma.cfExtract.aggregate({
|
await prisma.cfExtract.aggregate({
|
||||||
@@ -76,30 +96,19 @@ export async function enqueueOrder(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
g.__epayQueue!.push({ extractId: record.id, input });
|
items.push({ extractId: record.id, input });
|
||||||
|
}
|
||||||
|
|
||||||
|
g.__epayBatchQueue!.push({ items });
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[epay-queue] Enqueued: ${input.nrCadastral} (id=${record.id}, queue=${g.__epayQueue!.length})`,
|
`[epay-queue] Enqueued batch of ${items.length}: ${items.map((i) => i.input.nrCadastral).join(", ")} (queue=${g.__epayBatchQueue!.length})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start processing if not already running
|
// Start processing if not already running
|
||||||
void processQueue();
|
void processQueue();
|
||||||
|
|
||||||
return record.id;
|
return items.map((i) => i.extractId);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enqueue multiple orders at once (bulk).
|
|
||||||
*/
|
|
||||||
export async function enqueueBulk(
|
|
||||||
inputs: CfExtractCreateInput[],
|
|
||||||
): Promise<string[]> {
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (const input of inputs) {
|
|
||||||
const id = await enqueueOrder(input);
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,10 +117,9 @@ export async function enqueueBulk(
|
|||||||
export function getQueueStatus(): {
|
export function getQueueStatus(): {
|
||||||
length: number;
|
length: number;
|
||||||
processing: boolean;
|
processing: boolean;
|
||||||
currentItem?: string;
|
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
length: g.__epayQueue?.length ?? 0,
|
length: g.__epayBatchQueue?.length ?? 0,
|
||||||
processing: g.__epayQueueProcessing ?? false,
|
processing: g.__epayQueueProcessing ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -124,13 +132,13 @@ async function processQueue(): Promise<void> {
|
|||||||
if (g.__epayQueueProcessing) return; // already running
|
if (g.__epayQueueProcessing) return; // already running
|
||||||
g.__epayQueueProcessing = true;
|
g.__epayQueueProcessing = true;
|
||||||
|
|
||||||
// Track all orderIds from this batch to avoid duplicates
|
// Track all orderIds from this session to avoid duplicates
|
||||||
const knownOrderIds = new Set<string>();
|
const knownOrderIds = new Set<string>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (g.__epayQueue && g.__epayQueue.length > 0) {
|
while (g.__epayBatchQueue && g.__epayBatchQueue.length > 0) {
|
||||||
const item = g.__epayQueue.shift()!;
|
const batch = g.__epayBatchQueue.shift()!;
|
||||||
const orderId = await processItem(item, knownOrderIds);
|
const orderId = await processBatch(batch.items, knownOrderIds);
|
||||||
if (orderId) knownOrderIds.add(orderId);
|
if (orderId) knownOrderIds.add(orderId);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,52 +157,62 @@ async function updateStatus(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processItem(
|
/**
|
||||||
item: QueueItem,
|
* Process a batch of items as ONE ePay order:
|
||||||
|
* 1. Check credits (>= N)
|
||||||
|
* 2. addToCart + saveMetadata for each
|
||||||
|
* 3. ONE submitOrder
|
||||||
|
* 4. Poll until complete
|
||||||
|
* 5. Download + store ALL documents
|
||||||
|
*/
|
||||||
|
async function processBatch(
|
||||||
|
items: QueueItem[],
|
||||||
knownOrderIds: Set<string>,
|
knownOrderIds: Set<string>,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const { extractId, input } = item;
|
const extractIds = items.map((i) => i.extractId);
|
||||||
|
const count = items.length;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get ePay credentials
|
// Get ePay credentials
|
||||||
const creds = getEpayCredentials();
|
const creds = getEpayCredentials();
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
await updateStatus(extractId, "failed", {
|
for (const id of extractIds) {
|
||||||
|
await updateStatus(id, "failed", {
|
||||||
errorMessage: "Nu ești conectat la ePay.",
|
errorMessage: "Nu ești conectat la ePay.",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await EpayClient.create(creds.username, creds.password);
|
const client = await EpayClient.create(creds.username, creds.password);
|
||||||
|
|
||||||
// Step 1: Check credits
|
// Step 1: Check credits (need >= count)
|
||||||
const credits = await client.getCredits();
|
const credits = await client.getCredits();
|
||||||
updateEpayCredits(credits);
|
updateEpayCredits(credits);
|
||||||
if (credits < 1) {
|
if (credits < count) {
|
||||||
await updateStatus(extractId, "failed", {
|
for (const id of extractIds) {
|
||||||
errorMessage: `Credite insuficiente: ${credits}. Reîncărcați contul pe epay.ancpi.ro.`,
|
await updateStatus(id, "failed", {
|
||||||
|
errorMessage: `Credite insuficiente: ${credits} disponibile, ${count} necesare.`,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Add to cart
|
// Step 2: addToCart + saveMetadata for EACH item
|
||||||
|
for (const item of items) {
|
||||||
|
const { extractId, input } = item;
|
||||||
|
|
||||||
await updateStatus(extractId, "cart");
|
await updateStatus(extractId, "cart");
|
||||||
const basketRowId = await client.addToCart(input.prodId ?? 14200);
|
const basketRowId = await client.addToCart(input.prodId ?? 14200);
|
||||||
|
item.basketRowId = basketRowId;
|
||||||
await updateStatus(extractId, "cart", { basketRowId });
|
await updateStatus(extractId, "cart", { basketRowId });
|
||||||
|
|
||||||
// Step 3: Save metadata + submit order
|
// Resolve county/UAT from SIRUTA if available
|
||||||
// ePay internal county IDs = eTerra WORKSPACE_IDs
|
let countyInternalId = input.judetIndex;
|
||||||
// ePay UAT IDs = SIRUTA codes
|
let uatInternalId = input.uatId;
|
||||||
// So we can skip all discovery calls!
|
|
||||||
await updateStatus(extractId, "ordering");
|
|
||||||
|
|
||||||
// Resolve county ID (workspacePk) and UAT ID (siruta) from our DB
|
|
||||||
let countyInternalId = input.judetIndex; // might already be workspacePk
|
|
||||||
let uatInternalId = input.uatId; // might already be SIRUTA
|
|
||||||
let countyName = input.judetName;
|
let countyName = input.judetName;
|
||||||
let uatName = input.uatName;
|
let uatName = input.uatName;
|
||||||
|
|
||||||
// If siruta is provided, look up workspacePk from GisUat
|
|
||||||
if (input.siruta) {
|
if (input.siruta) {
|
||||||
const uat = await prisma.gisUat.findUnique({
|
const uat = await prisma.gisUat.findUnique({
|
||||||
where: { siruta: input.siruta },
|
where: { siruta: input.siruta },
|
||||||
@@ -209,6 +227,8 @@ async function processItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nrCF = input.nrCF ?? input.nrCadastral;
|
const nrCF = input.nrCF ?? input.nrCadastral;
|
||||||
|
await updateStatus(extractId, "ordering");
|
||||||
|
|
||||||
const saved = await client.saveMetadata(
|
const saved = await client.saveMetadata(
|
||||||
basketRowId,
|
basketRowId,
|
||||||
countyInternalId,
|
countyInternalId,
|
||||||
@@ -219,25 +239,44 @@ async function processItem(
|
|||||||
input.nrCadastral,
|
input.nrCadastral,
|
||||||
process.env.ANCPI_DEFAULT_SOLICITANT_ID || "14452",
|
process.env.ANCPI_DEFAULT_SOLICITANT_ID || "14452",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
await updateStatus(extractId, "failed", {
|
await updateStatus(extractId, "failed", {
|
||||||
errorMessage: "Salvarea metadatelor în ePay a eșuat.",
|
errorMessage: "Salvarea metadatelor în ePay a eșuat.",
|
||||||
});
|
});
|
||||||
|
// Continue with remaining items — the cart still has them
|
||||||
|
// but this one won't get metadata. Remove from batch.
|
||||||
|
item.basketRowId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only items that had successful metadata saves
|
||||||
|
const validItems = items.filter((i) => i.basketRowId !== undefined);
|
||||||
|
if (validItems.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 3: ONE submitOrder for ALL items
|
||||||
|
console.log(
|
||||||
|
`[epay-queue] Submitting order for ${validItems.length} items...`,
|
||||||
|
);
|
||||||
const orderId = await client.submitOrder(knownOrderIds);
|
const orderId = await client.submitOrder(knownOrderIds);
|
||||||
|
|
||||||
await updateStatus(extractId, "polling", { orderId });
|
// Update all valid items with the shared orderId
|
||||||
|
for (const item of validItems) {
|
||||||
|
await updateStatus(item.extractId, "polling", { orderId });
|
||||||
|
}
|
||||||
|
|
||||||
// Step 5: Poll until complete
|
// Step 4: Poll until complete
|
||||||
const finalStatus = await client.pollUntilComplete(
|
const finalStatus = await client.pollUntilComplete(
|
||||||
orderId,
|
orderId,
|
||||||
async (attempt, status) => {
|
async (attempt, status) => {
|
||||||
await updateStatus(extractId, "polling", {
|
for (const item of validItems) {
|
||||||
|
await updateStatus(item.extractId, "polling", {
|
||||||
epayStatus: status,
|
epayStatus: status,
|
||||||
pollAttempts: attempt,
|
pollAttempts: attempt,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -245,26 +284,57 @@ async function processItem(
|
|||||||
finalStatus.status === "Anulata" ||
|
finalStatus.status === "Anulata" ||
|
||||||
finalStatus.status === "Plata refuzata"
|
finalStatus.status === "Plata refuzata"
|
||||||
) {
|
) {
|
||||||
await updateStatus(extractId, "cancelled", {
|
for (const item of validItems) {
|
||||||
|
await updateStatus(item.extractId, "cancelled", {
|
||||||
epayStatus: finalStatus.status,
|
epayStatus: finalStatus.status,
|
||||||
errorMessage: `Comanda ${finalStatus.status.toLowerCase()}.`,
|
errorMessage: `Comanda ${finalStatus.status.toLowerCase()}.`,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Download PDF
|
// Step 5: Download ALL documents and match to items
|
||||||
const doc = finalStatus.documents.find(
|
// The order may contain multiple documents — match by filename/nrCadastral
|
||||||
|
const downloadableDocs = finalStatus.documents.filter(
|
||||||
(d) => d.downloadValabil && d.contentType === "application/pdf",
|
(d) => d.downloadValabil && d.contentType === "application/pdf",
|
||||||
);
|
);
|
||||||
if (!doc) {
|
|
||||||
await updateStatus(extractId, "failed", {
|
console.log(
|
||||||
|
`[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.
|
||||||
|
|
||||||
|
if (downloadableDocs.length === 0) {
|
||||||
|
for (const item of validItems) {
|
||||||
|
await updateStatus(item.extractId, "failed", {
|
||||||
epayStatus: finalStatus.status,
|
epayStatus: finalStatus.status,
|
||||||
errorMessage: "Nu s-a găsit documentul PDF în comanda finalizată.",
|
errorMessage: "Nu s-au găsit documente PDF în comanda finalizată.",
|
||||||
});
|
});
|
||||||
return null;
|
}
|
||||||
|
return orderId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateStatus(extractId, "downloading", {
|
// 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];
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
await updateStatus(item.extractId, "failed", {
|
||||||
|
epayStatus: finalStatus.status,
|
||||||
|
errorMessage: `Document lipsă (${downloadableDocs.length} documente pentru ${validItems.length} parcele).`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateStatus(item.extractId, "downloading", {
|
||||||
idDocument: doc.idDocument,
|
idDocument: doc.idDocument,
|
||||||
documentName: doc.nume,
|
documentName: doc.nume,
|
||||||
documentDate: doc.dataDocument ? new Date(doc.dataDocument) : null,
|
documentDate: doc.dataDocument ? new Date(doc.dataDocument) : null,
|
||||||
@@ -272,29 +342,29 @@ async function processItem(
|
|||||||
|
|
||||||
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
|
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
|
||||||
|
|
||||||
// Step 7: Store in MinIO
|
// Step 6: Store in MinIO
|
||||||
const { path, index } = await storeCfExtract(
|
const { path, index } = await storeCfExtract(
|
||||||
pdfBuffer,
|
pdfBuffer,
|
||||||
input.nrCadastral,
|
item.input.nrCadastral,
|
||||||
{
|
{
|
||||||
"ancpi-order-id": orderId,
|
"ancpi-order-id": orderId,
|
||||||
"nr-cadastral": input.nrCadastral,
|
"nr-cadastral": item.input.nrCadastral,
|
||||||
judet: input.judetName,
|
judet: item.input.judetName,
|
||||||
uat: input.uatName,
|
uat: item.input.uatName,
|
||||||
"data-document": doc.dataDocument ?? "",
|
"data-document": doc.dataDocument ?? "",
|
||||||
stare: finalStatus.status,
|
stare: finalStatus.status,
|
||||||
produs: "EXI_ONLINE",
|
produs: "EXI_ONLINE",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 8: Complete
|
// Complete
|
||||||
const documentDate = doc.dataDocument
|
const documentDate = doc.dataDocument
|
||||||
? new Date(doc.dataDocument)
|
? new Date(doc.dataDocument)
|
||||||
: new Date();
|
: new Date();
|
||||||
const expiresAt = new Date(documentDate);
|
const expiresAt = new Date(documentDate);
|
||||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||||
|
|
||||||
await updateStatus(extractId, "completed", {
|
await updateStatus(item.extractId, "completed", {
|
||||||
minioPath: path,
|
minioPath: path,
|
||||||
minioIndex: index,
|
minioIndex: index,
|
||||||
epayStatus: finalStatus.status,
|
epayStatus: finalStatus.status,
|
||||||
@@ -303,19 +373,33 @@ async function processItem(
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[epay-queue] Completed: ${item.input.nrCadastral} → ${path}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Eroare download/stocare";
|
||||||
|
await updateStatus(item.extractId, "failed", {
|
||||||
|
errorMessage: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update credits after successful order
|
// Update credits after successful order
|
||||||
const newCredits = await client.getCredits();
|
const newCredits = await client.getCredits();
|
||||||
updateEpayCredits(newCredits);
|
updateEpayCredits(newCredits);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[epay-queue] Completed: ${input.nrCadastral} → ${path} (credits: ${newCredits})`,
|
`[epay-queue] Batch complete: orderId=${orderId}, credits remaining=${newCredits}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return orderId;
|
return orderId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare necunoscută";
|
const message =
|
||||||
console.error(`[epay-queue] Failed: ${input.nrCadastral}:`, message);
|
error instanceof Error ? error.message : "Eroare necunoscută";
|
||||||
await updateStatus(extractId, "failed", { errorMessage: message });
|
console.error(`[epay-queue] Batch failed:`, message);
|
||||||
|
for (const id of extractIds) {
|
||||||
|
await updateStatus(id, "failed", { errorMessage: message });
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user