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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user