feat(parcel-sync): add ANCPI ePay CF extract ordering backend

Foundation (Phase 1):
- CfExtract Prisma model with version tracking, expiry, MinIO path
- epay-types.ts: all ePay API response types
- epay-counties.ts: WORKSPACE_ID → ePay county index mapping (42 counties)
- epay-storage.ts: MinIO helpers (bucket, naming, upload, download)
- docker-compose.yml: ANCPI env vars

ePay Client (Phase 2):
- epay-client.ts: full HTTP client (login, credits, cart, search estate,
  submit order, poll status, download PDF) with cookie jar + auto-relogin
- epay-session-store.ts: separate session from eTerra

Queue + API (Phase 3):
- epay-queue.ts: sequential FIFO queue (global cart constraint),
  10-step workflow per order with DB status updates at each step
- POST /api/ancpi/session: connect/disconnect
- POST /api/ancpi/order: create single or bulk orders
- GET /api/ancpi/orders: list all extracts
- GET /api/ancpi/credits: live credit balance
- GET /api/ancpi/download: stream PDF from MinIO

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 00:09:52 +02:00
parent f6781ab851
commit 3921852eb5
13 changed files with 1618 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import {
getEpayCredentials,
getEpaySessionStatus,
updateEpayCredits,
} from "@/modules/parcel-sync/services/epay-session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** GET /api/ancpi/credits — current credit balance (live from ePay) */
export async function GET() {
try {
const status = getEpaySessionStatus();
if (!status.connected) {
return NextResponse.json({ credits: null, connected: false });
}
// Return cached if checked within last 60 seconds
const lastChecked = status.creditsCheckedAt
? new Date(status.creditsCheckedAt).getTime()
: 0;
if (Date.now() - lastChecked < 60_000 && status.credits != null) {
return NextResponse.json({
credits: status.credits,
connected: true,
cached: true,
});
}
// Fetch live from ePay
const creds = getEpayCredentials();
if (!creds) {
return NextResponse.json({ credits: null, connected: false });
}
const client = await EpayClient.create(creds.username, creds.password);
const credits = await client.getCredits();
updateEpayCredits(credits);
return NextResponse.json({ credits, connected: true, cached: false });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ error: message, credits: null }, { status: 500 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import { Readable } from "stream";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/download?id={extractId}
*
* Streams the CF extract PDF from MinIO with proper filename.
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "Parametru 'id' lipsă." },
{ status: 400 },
);
}
const extract = await prisma.cfExtract.findUnique({
where: { id },
select: { minioPath: true, nrCadastral: true, minioIndex: true },
});
if (!extract?.minioPath) {
return NextResponse.json(
{ error: "Extras CF negăsit sau fără fișier." },
{ status: 404 },
);
}
const stream = await getCfExtractStream(extract.minioPath);
// Convert Node.js Readable to Web ReadableStream
const webStream = new ReadableStream({
start(controller) {
stream.on("data", (chunk: Buffer) =>
controller.enqueue(new Uint8Array(chunk)),
);
stream.on("end", () => controller.close());
stream.on("error", (err: Error) => controller.error(err));
},
});
// Build display filename
const fileName =
extract.minioPath.split("/").pop() ??
`Extras_CF_${extract.nrCadastral}.pdf`;
return new Response(webStream, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { getEpayCredentials } from "@/modules/parcel-sync/services/epay-session-store";
import {
enqueueOrder,
enqueueBulk,
} from "@/modules/parcel-sync/services/epay-queue";
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* POST /api/ancpi/order — create one or more CF extract orders.
*
* Body: { parcels: CfExtractCreateInput[] }
* Returns: { orders: [{ id, nrCadastral, status }] }
*/
export async function POST(req: Request) {
try {
const creds = getEpayCredentials();
if (!creds) {
return NextResponse.json(
{ error: "Nu ești conectat la ePay ANCPI." },
{ status: 401 },
);
}
const body = (await req.json()) as {
parcels?: CfExtractCreateInput[];
};
const parcels = body.parcels ?? [];
if (parcels.length === 0) {
return NextResponse.json(
{ error: "Nicio parcelă specificată." },
{ status: 400 },
);
}
// Validate required fields
for (const p of parcels) {
if (!p.nrCadastral || p.judetIndex == null || p.uatId == null) {
return NextResponse.json(
{
error: `Date lipsă pentru parcela ${p.nrCadastral ?? "?"}. Necesare: nrCadastral, judetIndex, uatId.`,
},
{ status: 400 },
);
}
}
if (parcels.length === 1) {
const id = await enqueueOrder(parcels[0]!);
return NextResponse.json({
orders: [
{
id,
nrCadastral: parcels[0]!.nrCadastral,
status: "queued",
},
],
});
}
const ids = await enqueueBulk(parcels);
const orders = ids.map((id, i) => ({
id,
nrCadastral: parcels[i]?.nrCadastral ?? "",
status: "queued",
}));
return NextResponse.json({ orders });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/orders — list all CF extract orders.
*
* Query params: ?nrCadastral=&status=&limit=50&offset=0
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const nrCadastral = url.searchParams.get("nrCadastral") || undefined;
const status = url.searchParams.get("status") || undefined;
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200);
const offset = parseInt(url.searchParams.get("offset") ?? "0");
const where: Record<string, unknown> = {};
if (nrCadastral) where.nrCadastral = nrCadastral;
if (status) where.status = status;
const [orders, total] = await Promise.all([
prisma.cfExtract.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
}),
prisma.cfExtract.count({ where }),
]);
return NextResponse.json({ orders, total });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import {
createEpaySession,
destroyEpaySession,
getEpaySessionStatus,
} from "@/modules/parcel-sync/services/epay-session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** GET /api/ancpi/session — status + credits */
export async function GET() {
return NextResponse.json(getEpaySessionStatus());
}
/** POST /api/ancpi/session — connect or disconnect */
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
action?: string;
username?: string;
password?: string;
};
if (body.action === "disconnect") {
destroyEpaySession();
return NextResponse.json({ success: true, disconnected: true });
}
// Connect
const username = (
body.username ?? process.env.ANCPI_USERNAME ?? ""
).trim();
const password = (
body.password ?? process.env.ANCPI_PASSWORD ?? ""
).trim();
if (!username || !password) {
return NextResponse.json(
{ error: "Credențiale ANCPI lipsă" },
{ status: 400 },
);
}
const client = await EpayClient.create(username, password);
const credits = await client.getCredits();
createEpaySession(username, password, credits);
return NextResponse.json({ success: true, credits });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
const status = message.toLowerCase().includes("login") ? 401 : 500;
return NextResponse.json({ error: message }, { status });
}
}