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:
@@ -44,6 +44,13 @@ services:
|
|||||||
# eTerra ANCPI (parcel-sync module)
|
# eTerra ANCPI (parcel-sync module)
|
||||||
- ETERRA_USERNAME=${ETERRA_USERNAME:-}
|
- ETERRA_USERNAME=${ETERRA_USERNAME:-}
|
||||||
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
|
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
|
||||||
|
# ANCPI ePay (CF extract ordering)
|
||||||
|
- ANCPI_USERNAME=${ANCPI_USERNAME:-}
|
||||||
|
- ANCPI_PASSWORD=${ANCPI_PASSWORD:-}
|
||||||
|
- ANCPI_BASE_URL=https://epay.ancpi.ro/epay
|
||||||
|
- ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login
|
||||||
|
- ANCPI_DEFAULT_SOLICITANT_ID=14452
|
||||||
|
- MINIO_BUCKET_ANCPI=ancpi-documente
|
||||||
# iLovePDF cloud compression (free: 250 files/month)
|
# iLovePDF cloud compression (free: 250 files/month)
|
||||||
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
|
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
|
||||||
# DWG-to-DXF sidecar
|
# DWG-to-DXF sidecar
|
||||||
|
|||||||
@@ -115,3 +115,50 @@ model RegistryAudit {
|
|||||||
@@index([entryId])
|
@@index([entryId])
|
||||||
@@index([company, createdAt])
|
@@index([company, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── ANCPI ePay: CF Extract Orders ──────────────────────────────────
|
||||||
|
|
||||||
|
model CfExtract {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orderId String? @unique // ePay orderId
|
||||||
|
basketRowId Int? // ePay cart item ID
|
||||||
|
nrCadastral String // cadastral number
|
||||||
|
nrCF String? // CF number if different
|
||||||
|
siruta String? // UAT SIRUTA code
|
||||||
|
judetIndex Int // ePay county index (0-41)
|
||||||
|
judetName String // county display name
|
||||||
|
uatId Int // ePay UAT numeric ID
|
||||||
|
uatName String // UAT display name
|
||||||
|
prodId Int @default(14200)
|
||||||
|
solicitantId String @default("14452")
|
||||||
|
status String @default("pending") // pending|queued|cart|searching|ordering|polling|downloading|completed|failed|cancelled
|
||||||
|
epayStatus String? // raw ePay status
|
||||||
|
idDocument Int? // ePay document ID
|
||||||
|
documentName String? // ePay filename
|
||||||
|
documentDate DateTime? // when ANCPI generated
|
||||||
|
minioPath String? // MinIO object key
|
||||||
|
minioIndex Int? // file version index
|
||||||
|
creditsUsed Int @default(1)
|
||||||
|
immovableId String? // eTerra immovable ID
|
||||||
|
immovableType String? // T/C/A
|
||||||
|
measuredArea String?
|
||||||
|
legalArea String?
|
||||||
|
address String?
|
||||||
|
gisFeatureId String? // link to GisFeature
|
||||||
|
version Int @default(1) // increments on re-order
|
||||||
|
expiresAt DateTime? // 30 days after documentDate
|
||||||
|
supersededById String? // newer version id
|
||||||
|
requestedBy String?
|
||||||
|
errorMessage String?
|
||||||
|
pollAttempts Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
@@index([nrCadastral])
|
||||||
|
@@index([status])
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([gisFeatureId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([nrCadastral, version])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
/**
|
||||||
|
* ANCPI ePay client — server-side only.
|
||||||
|
*
|
||||||
|
* Authenticates via oassl.ancpi.ro, keeps cookie jar (iPlanetDirectoryPro + JSESSIONID),
|
||||||
|
* and implements the full CF extract ordering workflow:
|
||||||
|
* login → check credits → add to cart → search estate →
|
||||||
|
* configure & submit → poll status → download PDF
|
||||||
|
*
|
||||||
|
* Modeled after eterra-client.ts: cookie jar, session cache, retry, auto-relogin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { wrapper } from "axios-cookiejar-support";
|
||||||
|
import { CookieJar } from "tough-cookie";
|
||||||
|
import type {
|
||||||
|
EpayCartResponse,
|
||||||
|
EpaySearchResult,
|
||||||
|
EpayUatEntry,
|
||||||
|
EpayOrderStatus,
|
||||||
|
EpaySolutionDoc,
|
||||||
|
OrderMetadata,
|
||||||
|
} from "./epay-types";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const BASE_URL = process.env.ANCPI_BASE_URL || "https://epay.ancpi.ro/epay";
|
||||||
|
const LOGIN_URL =
|
||||||
|
process.env.ANCPI_LOGIN_URL ||
|
||||||
|
"https://oassl.ancpi.ro/openam/UI/Login";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 60_000; // ePay is slow
|
||||||
|
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour (ePay sessions last longer)
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const POLL_INTERVAL_MS = 15_000;
|
||||||
|
const POLL_MAX_ATTEMPTS = 40; // 10 minutes
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Session cache (global singleton) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type SessionEntry = {
|
||||||
|
jar: CookieJar;
|
||||||
|
client: AxiosInstance;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalStore = globalThis as {
|
||||||
|
__epaySessionCache?: Map<string, SessionEntry>;
|
||||||
|
};
|
||||||
|
const sessionCache =
|
||||||
|
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
|
||||||
|
globalStore.__epaySessionCache = sessionCache;
|
||||||
|
|
||||||
|
const makeCacheKey = (u: string, p: string) =>
|
||||||
|
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const isRedirectToLogin = (html: string) =>
|
||||||
|
html.includes("IDToken1") ||
|
||||||
|
html.includes("/openam/UI/Login") ||
|
||||||
|
html.includes("j_security_check");
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Client */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export class EpayClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private jar: CookieJar;
|
||||||
|
private username: string;
|
||||||
|
private password: string;
|
||||||
|
private reloginAttempted = false;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
client: AxiosInstance,
|
||||||
|
jar: CookieJar,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
|
this.client = client;
|
||||||
|
this.jar = jar;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Factory ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static async create(username: string, password: string): Promise<EpayClient> {
|
||||||
|
const cacheKey = makeCacheKey(username, password);
|
||||||
|
const cached = sessionCache.get(cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cached && now - cached.lastUsed < SESSION_TTL_MS) {
|
||||||
|
cached.lastUsed = now;
|
||||||
|
return new EpayClient(cached.client, cached.jar, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jar = new CookieJar();
|
||||||
|
const client = wrapper(
|
||||||
|
axios.create({
|
||||||
|
jar,
|
||||||
|
withCredentials: true,
|
||||||
|
maxRedirects: 5,
|
||||||
|
headers: {
|
||||||
|
Accept:
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const instance = new EpayClient(client, jar, username, password);
|
||||||
|
await instance.login();
|
||||||
|
sessionCache.set(cacheKey, { jar, client, createdAt: now, lastUsed: now });
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Auth ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
private async login(): Promise<void> {
|
||||||
|
const body = `IDToken1=${encodeURIComponent(this.username)}&IDToken2=${encodeURIComponent(this.password)}`;
|
||||||
|
|
||||||
|
const response = await this.client.post(LOGIN_URL, body, {
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
maxRedirects: 10,
|
||||||
|
validateStatus: () => true, // don't throw on redirects
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if login succeeded — after redirects we should land on ePay
|
||||||
|
const finalUrl = response.request?.res?.responseUrl ?? response.config?.url ?? "";
|
||||||
|
const html = typeof response.data === "string" ? response.data : "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
finalUrl.includes("/openam/UI/Login") ||
|
||||||
|
html.includes("Authentication Failed") ||
|
||||||
|
html.includes("IDToken1")
|
||||||
|
) {
|
||||||
|
throw new Error("ePay login failed (invalid credentials)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we have session cookies
|
||||||
|
const cookies = await this.jar.getCookies(BASE_URL);
|
||||||
|
const hasCookies =
|
||||||
|
cookies.some((c) => c.key === "JSESSIONID") ||
|
||||||
|
cookies.some((c) => c.key === "iPlanetDirectoryPro");
|
||||||
|
|
||||||
|
if (!hasCookies) {
|
||||||
|
// Check oassl domain too
|
||||||
|
const oasslCookies = await this.jar.getCookies(LOGIN_URL);
|
||||||
|
if (!oasslCookies.some((c) => c.key === "iPlanetDirectoryPro")) {
|
||||||
|
throw new Error("ePay login failed (no session cookie)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[epay] Login successful.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureSession(): Promise<void> {
|
||||||
|
if (this.reloginAttempted) return;
|
||||||
|
// Re-login if needed (lazy check — will be triggered on redirects)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retryOnAuthFail<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
const html =
|
||||||
|
typeof err?.response?.data === "string" ? err.response.data : "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
(err?.response?.status === 302 || isRedirectToLogin(html)) &&
|
||||||
|
!this.reloginAttempted
|
||||||
|
) {
|
||||||
|
this.reloginAttempted = true;
|
||||||
|
await this.login();
|
||||||
|
this.reloginAttempted = false;
|
||||||
|
return await fn();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Credits ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async getCredits(): Promise<number> {
|
||||||
|
return this.retryOnAuthFail(async () => {
|
||||||
|
const response = await this.client.get(`${BASE_URL}/LogIn.action`, {
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const html = String(response.data ?? "");
|
||||||
|
|
||||||
|
// Parse "Ai {N} puncte de credit"
|
||||||
|
const match = html.match(/Ai\s+(\d+)\s+puncte?\s+de\s+credit/i);
|
||||||
|
if (match) return parseInt(match[1] ?? "0", 10);
|
||||||
|
|
||||||
|
// Fallback: look for credit count in other formats
|
||||||
|
const match2 = html.match(/(\d+)\s+puncte?\s+de\s+credit/i);
|
||||||
|
if (match2) return parseInt(match2[1] ?? "0", 10);
|
||||||
|
|
||||||
|
console.warn("[epay] Could not parse credits from HTML");
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cart ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async addToCart(prodId = 14200): Promise<number> {
|
||||||
|
return this.retryOnAuthFail(async () => {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("prodId", String(prodId));
|
||||||
|
body.set("productQtyModif", "1");
|
||||||
|
body.set("urgencyValue", "5000");
|
||||||
|
body.set("wishListId", "-1");
|
||||||
|
body.set("basketRowId", "");
|
||||||
|
body.set("addToWishList", "");
|
||||||
|
body.set("random", String(Date.now()));
|
||||||
|
body.set("secondaryProductsJson", "");
|
||||||
|
|
||||||
|
const response = await this.client.post(
|
||||||
|
`${BASE_URL}/AddToCartOrWishListFromPost.action`,
|
||||||
|
body.toString(),
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = response.data as EpayCartResponse;
|
||||||
|
const item = data?.items?.[0];
|
||||||
|
if (!item?.id) {
|
||||||
|
throw new Error(
|
||||||
|
`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[epay] Added to cart: basketRowId=${item.id}`);
|
||||||
|
return item.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Estate Search ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async searchEstate(
|
||||||
|
identifier: string,
|
||||||
|
countyIdx: number,
|
||||||
|
uatId: number,
|
||||||
|
): Promise<EpaySearchResult[]> {
|
||||||
|
return this.retryOnAuthFail(async () => {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("identifier", identifier);
|
||||||
|
body.set("countyId", String(countyIdx));
|
||||||
|
body.set("uatId", String(uatId));
|
||||||
|
|
||||||
|
const response = await this.client.post(
|
||||||
|
`${BASE_URL}/SearchEstate.action`,
|
||||||
|
body.toString(),
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
if (Array.isArray(data)) return data as EpaySearchResult[];
|
||||||
|
|
||||||
|
// Sometimes wrapped in a string
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (Array.isArray(parsed)) return parsed as EpaySearchResult[];
|
||||||
|
} catch {
|
||||||
|
// Not JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── UAT Lookup ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async getUatList(countyIdx: number): Promise<EpayUatEntry[]> {
|
||||||
|
return this.retryOnAuthFail(async () => {
|
||||||
|
// ePay uses EpayJsonInterceptor for dynamic dropdowns
|
||||||
|
// Try the interceptor first
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("actionType", "getUAT");
|
||||||
|
body.set("countyIndex", String(countyIdx));
|
||||||
|
|
||||||
|
const response = await this.client.post(
|
||||||
|
`${BASE_URL}/EpayJsonInterceptor.action`,
|
||||||
|
body.toString(),
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// Response: { jsonResult: "[{\"id\":155546,\"value\":\"Balint\"}, ...]" }
|
||||||
|
if (data?.jsonResult) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.jsonResult);
|
||||||
|
if (Array.isArray(parsed)) return parsed as EpayUatEntry[];
|
||||||
|
} catch {
|
||||||
|
// Parse failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct array response
|
||||||
|
if (Array.isArray(data)) return data as EpayUatEntry[];
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[epay] getUatList(${countyIdx}) returned unexpected:`,
|
||||||
|
JSON.stringify(data).slice(0, 200),
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Order Submission ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
async submitOrder(metadata: OrderMetadata): Promise<string> {
|
||||||
|
return this.retryOnAuthFail(async () => {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("goToCheckout", "true");
|
||||||
|
body.set("basketItems[0].basketId", String(metadata.basketRowId));
|
||||||
|
body.set("basketItems[0].metadate.judet", String(metadata.judetIndex));
|
||||||
|
body.set("basketItems[0].metadate.uat", String(metadata.uatId));
|
||||||
|
body.set("basketItems[0].metadate.CF", metadata.nrCF);
|
||||||
|
body.set("basketItems[0].metadate.CAD", metadata.nrCadastral);
|
||||||
|
body.set("basketItems[0].metadate.metodeLivrare", "Electronic");
|
||||||
|
body.set("basketItems[0].solicitant", metadata.solicitantId);
|
||||||
|
|
||||||
|
const response = await this.client.post(
|
||||||
|
`${BASE_URL}/EditCartSubmit.action`,
|
||||||
|
body.toString(),
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
maxRedirects: 10,
|
||||||
|
validateStatus: () => true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// After submit, follow to CheckoutConfirmationSubmit
|
||||||
|
const finalUrl =
|
||||||
|
response.request?.res?.responseUrl ?? response.config?.url ?? "";
|
||||||
|
const html = String(response.data ?? "");
|
||||||
|
|
||||||
|
// Try to get the order ID from the confirmation page or redirect
|
||||||
|
// If we're on the confirmation page, parse orderId from the recent orders
|
||||||
|
if (
|
||||||
|
finalUrl.includes("CheckoutConfirmation") ||
|
||||||
|
html.includes("Vă mulțumim")
|
||||||
|
) {
|
||||||
|
return this.getLatestOrderId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If redirected back to cart, the submission may have failed
|
||||||
|
if (finalUrl.includes("ShowCartItems") || finalUrl.includes("ViewCart")) {
|
||||||
|
throw new Error("ePay order submission failed — redirected to cart");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get orderId anyway
|
||||||
|
return this.getLatestOrderId();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the most recent order ID from the order history page */
|
||||||
|
private async getLatestOrderId(): Promise<string> {
|
||||||
|
const response = await this.client.get(`${BASE_URL}/LogIn.action`, {
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const html = String(response.data ?? "");
|
||||||
|
|
||||||
|
// Parse orderId from the orders table
|
||||||
|
// Look for ShowOrderDetails.action?orderId=XXXXXXX
|
||||||
|
const match = html.match(
|
||||||
|
/ShowOrderDetails\.action\?orderId=(\d+)/,
|
||||||
|
);
|
||||||
|
if (match) return match[1] ?? "";
|
||||||
|
|
||||||
|
// Fallback: look for order ID patterns
|
||||||
|
const match2 = html.match(/orderId[=:][\s"]*(\d{5,})/);
|
||||||
|
if (match2) return match2[1] ?? "";
|
||||||
|
|
||||||
|
throw new Error("Could not determine orderId after checkout");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Order Status & Polling ────────────────────────────────── */
|
||||||
|
|
||||||
|
async getOrderStatus(orderId: string): Promise<EpayOrderStatus> {
|
||||||
|
return this.retryOnAuthFail(async () => {
|
||||||
|
const response = await this.client.get(
|
||||||
|
`${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}`,
|
||||||
|
{ timeout: DEFAULT_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const html = String(response.data ?? "");
|
||||||
|
|
||||||
|
// Parse status
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Parse documents — look for JSON-like solutie data in Angular scope
|
||||||
|
const documents: EpaySolutionDoc[] = [];
|
||||||
|
|
||||||
|
// Pattern: idDocument, nume, dataDocument, linkDownload
|
||||||
|
const docPattern =
|
||||||
|
/"idDocument"\s*:\s*(\d+)[\s\S]*?"nume"\s*:\s*"([^"]+)"[\s\S]*?"dataDocument"\s*:\s*"([^"]+)"[\s\S]*?"linkDownload"\s*:\s*"([^"]*)"/g;
|
||||||
|
let docMatch;
|
||||||
|
while ((docMatch = docPattern.exec(html)) !== null) {
|
||||||
|
documents.push({
|
||||||
|
idDocument: parseInt(docMatch[1] ?? "0", 10),
|
||||||
|
idTipDocument: null,
|
||||||
|
nume: docMatch[2] ?? "",
|
||||||
|
numar: null,
|
||||||
|
serie: null,
|
||||||
|
dataDocument: docMatch[3] ?? "",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
linkDownload: docMatch[4] ?? "",
|
||||||
|
downloadValabil: true,
|
||||||
|
valabilNelimitat: true,
|
||||||
|
zileValabilitateDownload: -1,
|
||||||
|
transactionId: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { orderId, status, documents };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll order status until completed or timeout.
|
||||||
|
* Returns the final status with document info.
|
||||||
|
*/
|
||||||
|
async pollUntilComplete(
|
||||||
|
orderId: string,
|
||||||
|
onProgress?: (attempt: number, status: string) => void,
|
||||||
|
): Promise<EpayOrderStatus> {
|
||||||
|
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
||||||
|
const status = await this.getOrderStatus(orderId);
|
||||||
|
|
||||||
|
if (onProgress) onProgress(attempt, status.status);
|
||||||
|
|
||||||
|
if (
|
||||||
|
status.status === "Finalizata" ||
|
||||||
|
status.status === "Anulata" ||
|
||||||
|
status.status === "Plata refuzata"
|
||||||
|
) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`ePay order ${orderId} timed out after ${POLL_MAX_ATTEMPTS} poll attempts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Document Download ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
async downloadDocument(
|
||||||
|
idDocument: number,
|
||||||
|
typeD = 4,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return this.retryOnAuthFail(async () => {
|
||||||
|
const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`;
|
||||||
|
|
||||||
|
const response = await this.client.post(url, null, {
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
if (!data || data.length < 100) {
|
||||||
|
throw new Error(
|
||||||
|
`ePay download returned empty/small response (${data?.length ?? 0} bytes)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[epay] Downloaded document ${idDocument}: ${data.length} bytes`,
|
||||||
|
);
|
||||||
|
return Buffer.from(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* ePay county/UAT mapping — bridges eTerra WORKSPACE_ID to ePay county indices.
|
||||||
|
*
|
||||||
|
* ePay uses alphabetical indices 0-41 for counties.
|
||||||
|
* eTerra uses WORKSPACE_IDs (10, 29, 38, ...).
|
||||||
|
* Our DB (GisUat) stores county names with diacritics ("Cluj", "Argeș").
|
||||||
|
*
|
||||||
|
* Resolution chain:
|
||||||
|
* SIRUTA → GisUat.county → resolveEpayCountyIndex() → ePay countyIdx
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** ePay county index → county name (uppercase, no diacritics) */
|
||||||
|
export const EPAY_COUNTIES: Record<number, string> = {
|
||||||
|
0: "ALBA",
|
||||||
|
1: "ARAD",
|
||||||
|
2: "ARGES",
|
||||||
|
3: "BACAU",
|
||||||
|
4: "BIHOR",
|
||||||
|
5: "BISTRITA NASAUD",
|
||||||
|
6: "BOTOSANI",
|
||||||
|
7: "BRAILA",
|
||||||
|
8: "BRASOV",
|
||||||
|
9: "BUCURESTI",
|
||||||
|
10: "BUZAU",
|
||||||
|
11: "CALARASI",
|
||||||
|
12: "CARASSEVERIN",
|
||||||
|
13: "CLUJ",
|
||||||
|
14: "CONSTANTA",
|
||||||
|
15: "COVASNA",
|
||||||
|
16: "DAMBOVITA",
|
||||||
|
17: "DOLJ",
|
||||||
|
18: "GALATI",
|
||||||
|
19: "GIURGIU",
|
||||||
|
20: "GORJ",
|
||||||
|
21: "HARGHITA",
|
||||||
|
22: "HUNEDOARA",
|
||||||
|
23: "IALOMITA",
|
||||||
|
24: "IASI",
|
||||||
|
25: "ILFOV",
|
||||||
|
26: "MARAMURES",
|
||||||
|
27: "MEHEDINTI",
|
||||||
|
28: "MURES",
|
||||||
|
29: "NEAMT",
|
||||||
|
30: "OLT",
|
||||||
|
31: "PRAHOVA",
|
||||||
|
32: "SALAJ",
|
||||||
|
33: "SATU MARE",
|
||||||
|
34: "SIBIU",
|
||||||
|
35: "SUCEAVA",
|
||||||
|
36: "TELEORMAN",
|
||||||
|
37: "TIMIS",
|
||||||
|
38: "TULCEA",
|
||||||
|
39: "VALCEA",
|
||||||
|
40: "VASLUI",
|
||||||
|
41: "VRANCEA",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reverse: ePay county name → index */
|
||||||
|
const EPAY_NAME_TO_INDEX = new Map<string, number>();
|
||||||
|
for (const [idx, name] of Object.entries(EPAY_COUNTIES)) {
|
||||||
|
EPAY_NAME_TO_INDEX.set(name, Number(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* eTerra county name (with diacritics) → ePay county index.
|
||||||
|
* Maps from WORKSPACE_TO_COUNTY values to ePay indices.
|
||||||
|
*/
|
||||||
|
const ETERRA_TO_EPAY: Record<string, number> = {
|
||||||
|
Alba: 0,
|
||||||
|
Arad: 1,
|
||||||
|
Argeș: 2,
|
||||||
|
Bacău: 3,
|
||||||
|
Bihor: 4,
|
||||||
|
"Bistrița-Năsăud": 5,
|
||||||
|
Botoșani: 6,
|
||||||
|
Brașov: 8,
|
||||||
|
Brăila: 7,
|
||||||
|
Buzău: 10,
|
||||||
|
"Caraș-Severin": 12,
|
||||||
|
Cluj: 13,
|
||||||
|
Constanța: 14,
|
||||||
|
Covasna: 15,
|
||||||
|
Dâmbovița: 16,
|
||||||
|
Dolj: 17,
|
||||||
|
Galați: 18,
|
||||||
|
Gorj: 20,
|
||||||
|
Harghita: 21,
|
||||||
|
Hunedoara: 22,
|
||||||
|
Ialomița: 23,
|
||||||
|
Iași: 24,
|
||||||
|
Ilfov: 25,
|
||||||
|
Maramureș: 26,
|
||||||
|
Mehedinți: 27,
|
||||||
|
Mureș: 28,
|
||||||
|
Neamț: 29,
|
||||||
|
Olt: 30,
|
||||||
|
Prahova: 31,
|
||||||
|
"Satu Mare": 33,
|
||||||
|
Sălaj: 32,
|
||||||
|
Sibiu: 34,
|
||||||
|
Suceava: 35,
|
||||||
|
Teleorman: 36,
|
||||||
|
Timiș: 37,
|
||||||
|
Tulcea: 38,
|
||||||
|
Vaslui: 40,
|
||||||
|
Vâlcea: 39,
|
||||||
|
Vrancea: 41,
|
||||||
|
București: 9,
|
||||||
|
Călărași: 11,
|
||||||
|
Giurgiu: 19,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Strip diacritics and uppercase for fuzzy matching */
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z\s]/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve ePay county index from a county name (with or without diacritics).
|
||||||
|
* Tries exact match first, then normalized match.
|
||||||
|
*/
|
||||||
|
export function resolveEpayCountyIndex(county: string): number | null {
|
||||||
|
// Direct match (eTerra county name with diacritics)
|
||||||
|
const direct = ETERRA_TO_EPAY[county];
|
||||||
|
if (direct !== undefined) return direct;
|
||||||
|
|
||||||
|
// Normalized match against ePay names
|
||||||
|
const norm = normalize(county);
|
||||||
|
const fromEpay = EPAY_NAME_TO_INDEX.get(norm);
|
||||||
|
if (fromEpay !== undefined) return fromEpay;
|
||||||
|
|
||||||
|
// Fuzzy: try without hyphens/spaces
|
||||||
|
const compact = norm.replace(/\s+/g, "");
|
||||||
|
for (const [name, idx] of EPAY_NAME_TO_INDEX) {
|
||||||
|
if (name.replace(/\s+/g, "") === compact) return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ePay county display name from index.
|
||||||
|
*/
|
||||||
|
export function getEpayCountyName(index: number): string | null {
|
||||||
|
return EPAY_COUNTIES[index] ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* ePay sequential order queue.
|
||||||
|
*
|
||||||
|
* ePay has a GLOBAL cart per account — only one order can be processed
|
||||||
|
* at a time. This queue ensures sequential execution.
|
||||||
|
*
|
||||||
|
* Flow per item:
|
||||||
|
* 1. Check credits
|
||||||
|
* 2. Add to cart (prodId=14200)
|
||||||
|
* 3. Search estate on ePay
|
||||||
|
* 4. Submit order (EditCartSubmit)
|
||||||
|
* 5. Poll status (15s × 40 = max 10 min)
|
||||||
|
* 6. Download PDF
|
||||||
|
* 7. Store in MinIO
|
||||||
|
* 8. Update DB record
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
import { EpayClient } from "./epay-client";
|
||||||
|
import { getEpayCredentials, updateEpayCredits } from "./epay-session-store";
|
||||||
|
import { storeCfExtract } from "./epay-storage";
|
||||||
|
import { resolveEpayCountyIndex } from "./epay-counties";
|
||||||
|
import type { CfExtractCreateInput } from "./epay-types";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type QueueItem = {
|
||||||
|
extractId: string; // CfExtract.id in DB
|
||||||
|
input: CfExtractCreateInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Global singleton queue */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const g = globalThis as {
|
||||||
|
__epayQueue?: QueueItem[];
|
||||||
|
__epayQueueProcessing?: boolean;
|
||||||
|
};
|
||||||
|
if (!g.__epayQueue) g.__epayQueue = [];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Public API */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a CF extract order. Creates a DB record and adds to the
|
||||||
|
* processing queue. Returns the CfExtract.id immediately.
|
||||||
|
*/
|
||||||
|
export async function enqueueOrder(
|
||||||
|
input: CfExtractCreateInput,
|
||||||
|
): Promise<string> {
|
||||||
|
// Create DB record in "queued" status
|
||||||
|
const record = await prisma.cfExtract.create({
|
||||||
|
data: {
|
||||||
|
nrCadastral: input.nrCadastral,
|
||||||
|
nrCF: input.nrCF ?? input.nrCadastral,
|
||||||
|
siruta: input.siruta,
|
||||||
|
judetIndex: input.judetIndex,
|
||||||
|
judetName: input.judetName,
|
||||||
|
uatId: input.uatId,
|
||||||
|
uatName: input.uatName,
|
||||||
|
gisFeatureId: input.gisFeatureId,
|
||||||
|
prodId: input.prodId ?? 14200,
|
||||||
|
status: "queued",
|
||||||
|
// Version: find max version for this cadastral number and increment
|
||||||
|
version:
|
||||||
|
((
|
||||||
|
await prisma.cfExtract.aggregate({
|
||||||
|
where: { nrCadastral: input.nrCadastral },
|
||||||
|
_max: { version: true },
|
||||||
|
})
|
||||||
|
)._max.version ?? 0) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
g.__epayQueue!.push({ extractId: record.id, input });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[epay-queue] Enqueued: ${input.nrCadastral} (id=${record.id}, queue=${g.__epayQueue!.length})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start processing if not already running
|
||||||
|
void processQueue();
|
||||||
|
|
||||||
|
return record.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue status for UI display.
|
||||||
|
*/
|
||||||
|
export function getQueueStatus(): {
|
||||||
|
length: number;
|
||||||
|
processing: boolean;
|
||||||
|
currentItem?: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
length: g.__epayQueue?.length ?? 0,
|
||||||
|
processing: g.__epayQueueProcessing ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Queue Processor */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function processQueue(): Promise<void> {
|
||||||
|
if (g.__epayQueueProcessing) return; // already running
|
||||||
|
g.__epayQueueProcessing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (g.__epayQueue && g.__epayQueue.length > 0) {
|
||||||
|
const item = g.__epayQueue.shift()!;
|
||||||
|
await processItem(item);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
g.__epayQueueProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
extra?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.cfExtract.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status, ...extra },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processItem(item: QueueItem): Promise<void> {
|
||||||
|
const { extractId, input } = item;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get ePay credentials
|
||||||
|
const creds = getEpayCredentials();
|
||||||
|
if (!creds) {
|
||||||
|
await updateStatus(extractId, "failed", {
|
||||||
|
errorMessage: "Nu ești conectat la ePay.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await EpayClient.create(creds.username, creds.password);
|
||||||
|
|
||||||
|
// Step 1: Check credits
|
||||||
|
const credits = await client.getCredits();
|
||||||
|
updateEpayCredits(credits);
|
||||||
|
if (credits < 1) {
|
||||||
|
await updateStatus(extractId, "failed", {
|
||||||
|
errorMessage: `Credite insuficiente: ${credits}. Reîncărcați contul pe epay.ancpi.ro.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Add to cart
|
||||||
|
await updateStatus(extractId, "cart");
|
||||||
|
const basketRowId = await client.addToCart(input.prodId ?? 14200);
|
||||||
|
await updateStatus(extractId, "cart", { basketRowId });
|
||||||
|
|
||||||
|
// Step 3: Search estate on ePay
|
||||||
|
await updateStatus(extractId, "searching");
|
||||||
|
const results = await client.searchEstate(
|
||||||
|
input.nrCadastral,
|
||||||
|
input.judetIndex,
|
||||||
|
input.uatId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
await updateStatus(extractId, "failed", {
|
||||||
|
errorMessage: `Imobilul ${input.nrCadastral} nu a fost găsit în baza ANCPI.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const estate = results[0]!;
|
||||||
|
await updateStatus(extractId, "searching", {
|
||||||
|
immovableId: estate.immovableId,
|
||||||
|
immovableType: estate.immovableTypeCode,
|
||||||
|
measuredArea: estate.measureadArea,
|
||||||
|
legalArea: estate.legalArea,
|
||||||
|
address: estate.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Submit order
|
||||||
|
await updateStatus(extractId, "ordering");
|
||||||
|
const nrCF = input.nrCF ?? estate.electronicIdentifier ?? input.nrCadastral;
|
||||||
|
const orderId = await client.submitOrder({
|
||||||
|
basketRowId,
|
||||||
|
judetIndex: input.judetIndex,
|
||||||
|
uatId: input.uatId,
|
||||||
|
nrCF,
|
||||||
|
nrCadastral: input.nrCadastral,
|
||||||
|
solicitantId:
|
||||||
|
process.env.ANCPI_DEFAULT_SOLICITANT_ID || "14452",
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateStatus(extractId, "polling", { orderId });
|
||||||
|
|
||||||
|
// Step 5: Poll until complete
|
||||||
|
const finalStatus = await client.pollUntilComplete(
|
||||||
|
orderId,
|
||||||
|
async (attempt, status) => {
|
||||||
|
await updateStatus(extractId, "polling", {
|
||||||
|
epayStatus: status,
|
||||||
|
pollAttempts: attempt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
finalStatus.status === "Anulata" ||
|
||||||
|
finalStatus.status === "Plata refuzata"
|
||||||
|
) {
|
||||||
|
await updateStatus(extractId, "cancelled", {
|
||||||
|
epayStatus: finalStatus.status,
|
||||||
|
errorMessage: `Comanda ${finalStatus.status.toLowerCase()}.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Download PDF
|
||||||
|
const doc = finalStatus.documents.find(
|
||||||
|
(d) => d.downloadValabil && d.contentType === "application/pdf",
|
||||||
|
);
|
||||||
|
if (!doc) {
|
||||||
|
await updateStatus(extractId, "failed", {
|
||||||
|
epayStatus: finalStatus.status,
|
||||||
|
errorMessage: "Nu s-a găsit documentul PDF în comanda finalizată.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateStatus(extractId, "downloading", {
|
||||||
|
idDocument: doc.idDocument,
|
||||||
|
documentName: doc.nume,
|
||||||
|
documentDate: doc.dataDocument ? new Date(doc.dataDocument) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
|
||||||
|
|
||||||
|
// Step 7: Store in MinIO
|
||||||
|
const { path, index } = await storeCfExtract(
|
||||||
|
pdfBuffer,
|
||||||
|
input.nrCadastral,
|
||||||
|
{
|
||||||
|
"ancpi-order-id": orderId,
|
||||||
|
"nr-cadastral": input.nrCadastral,
|
||||||
|
judet: input.judetName,
|
||||||
|
uat: input.uatName,
|
||||||
|
"data-document": doc.dataDocument ?? "",
|
||||||
|
stare: finalStatus.status,
|
||||||
|
produs: "EXI_ONLINE",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 8: Complete
|
||||||
|
const documentDate = doc.dataDocument
|
||||||
|
? new Date(doc.dataDocument)
|
||||||
|
: new Date();
|
||||||
|
const expiresAt = new Date(documentDate);
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||||
|
|
||||||
|
await updateStatus(extractId, "completed", {
|
||||||
|
minioPath: path,
|
||||||
|
minioIndex: index,
|
||||||
|
epayStatus: finalStatus.status,
|
||||||
|
completedAt: new Date(),
|
||||||
|
documentDate,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update credits after successful order
|
||||||
|
const newCredits = await client.getCredits();
|
||||||
|
updateEpayCredits(newCredits);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[epay-queue] Completed: ${input.nrCadastral} → ${path} (credits: ${newCredits})`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare necunoscută";
|
||||||
|
console.error(`[epay-queue] Failed: ${input.nrCadastral}:`, message);
|
||||||
|
await updateStatus(extractId, "failed", { errorMessage: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Server-side ePay session store (global singleton).
|
||||||
|
* Separate from eTerra session — different auth system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type EpaySession = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
connectedAt: string;
|
||||||
|
credits: number;
|
||||||
|
creditsCheckedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = globalThis as { __epaySession?: EpaySession | null };
|
||||||
|
|
||||||
|
export function createEpaySession(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
credits: number,
|
||||||
|
): void {
|
||||||
|
g.__epaySession = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
connectedAt: new Date().toISOString(),
|
||||||
|
credits,
|
||||||
|
creditsCheckedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyEpaySession(): void {
|
||||||
|
g.__epaySession = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEpayCredentials(): {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
} | null {
|
||||||
|
const session = g.__epaySession;
|
||||||
|
if (!session) return null;
|
||||||
|
return { username: session.username, password: session.password };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEpaySessionStatus(): {
|
||||||
|
connected: boolean;
|
||||||
|
username?: string;
|
||||||
|
connectedAt?: string;
|
||||||
|
credits?: number;
|
||||||
|
creditsCheckedAt?: string;
|
||||||
|
} {
|
||||||
|
const session = g.__epaySession;
|
||||||
|
if (!session) return { connected: false };
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
username: session.username,
|
||||||
|
connectedAt: session.connectedAt,
|
||||||
|
credits: session.credits,
|
||||||
|
creditsCheckedAt: session.creditsCheckedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEpayCredits(credits: number): void {
|
||||||
|
if (g.__epaySession) {
|
||||||
|
g.__epaySession.credits = credits;
|
||||||
|
g.__epaySession.creditsCheckedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* MinIO storage helpers for ANCPI CF extracts.
|
||||||
|
*
|
||||||
|
* Bucket: ancpi-documente (separate from general "tools" bucket)
|
||||||
|
* Naming: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { minioClient } from "@/core/storage/minio-client";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
|
||||||
|
const BUCKET = process.env.MINIO_BUCKET_ANCPI || "ancpi-documente";
|
||||||
|
|
||||||
|
let bucketChecked = false;
|
||||||
|
|
||||||
|
/** Ensure the ANCPI bucket exists (idempotent, cached) */
|
||||||
|
export async function ensureAncpiBucket(): Promise<void> {
|
||||||
|
if (bucketChecked) return;
|
||||||
|
try {
|
||||||
|
const exists = await minioClient.bucketExists(BUCKET);
|
||||||
|
if (!exists) {
|
||||||
|
await minioClient.makeBucket(BUCKET);
|
||||||
|
console.log(`[epay-storage] Bucket '${BUCKET}' created.`);
|
||||||
|
}
|
||||||
|
bucketChecked = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[epay-storage] Bucket check/create failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next file index for a given cadastral number.
|
||||||
|
* Scans existing objects to find the highest index.
|
||||||
|
*/
|
||||||
|
export async function getNextFileIndex(
|
||||||
|
nrCadastral: string,
|
||||||
|
): Promise<number> {
|
||||||
|
await ensureAncpiBucket();
|
||||||
|
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`^(\\d+)_Extras CF_${nrCadastral.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} -`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let maxIndex = 0;
|
||||||
|
const stream = minioClient.listObjects(BUCKET, "", true);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.on("data", (obj) => {
|
||||||
|
if (!obj.name) return;
|
||||||
|
const match = obj.name.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const idx = parseInt(match[1] ?? "0", 10);
|
||||||
|
if (idx > maxIndex) maxIndex = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on("end", () => resolve(maxIndex + 1));
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the display filename for a CF extract.
|
||||||
|
* Format: 01_Extras CF_291479 - 22-03-2026.pdf
|
||||||
|
*/
|
||||||
|
export function buildFileName(
|
||||||
|
index: number,
|
||||||
|
nrCadastral: string,
|
||||||
|
date: Date,
|
||||||
|
): string {
|
||||||
|
const idx = String(index).padStart(2, "0");
|
||||||
|
const dd = String(date.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yyyy = date.getFullYear();
|
||||||
|
return `${idx}_Extras CF_${nrCadastral} - ${dd}-${mm}-${yyyy}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a CF extract PDF in MinIO.
|
||||||
|
* Returns the MinIO path and file index.
|
||||||
|
*/
|
||||||
|
export async function storeCfExtract(
|
||||||
|
pdfBuffer: Buffer,
|
||||||
|
nrCadastral: string,
|
||||||
|
metadata: Record<string, string>,
|
||||||
|
): Promise<{ path: string; fileName: string; index: number }> {
|
||||||
|
await ensureAncpiBucket();
|
||||||
|
|
||||||
|
const index = await getNextFileIndex(nrCadastral);
|
||||||
|
const fileName = buildFileName(index, nrCadastral, new Date());
|
||||||
|
// Store in subfolder per cadastral number
|
||||||
|
const path = `parcele/${nrCadastral}/${fileName}`;
|
||||||
|
|
||||||
|
await minioClient.putObject(BUCKET, path, pdfBuffer, pdfBuffer.length, {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
...metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[epay-storage] Stored: ${path} (${pdfBuffer.length} bytes)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { path, fileName, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a readable stream for a stored CF extract.
|
||||||
|
*/
|
||||||
|
export async function getCfExtractStream(
|
||||||
|
minioPath: string,
|
||||||
|
): Promise<Readable> {
|
||||||
|
return minioClient.getObject(BUCKET, minioPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all stored CF extracts for a cadastral number.
|
||||||
|
*/
|
||||||
|
export async function listExtractsForParcel(
|
||||||
|
nrCadastral: string,
|
||||||
|
): Promise<Array<{ name: string; lastModified: Date; size: number }>> {
|
||||||
|
await ensureAncpiBucket();
|
||||||
|
|
||||||
|
const prefix = `parcele/${nrCadastral}/`;
|
||||||
|
const results: Array<{ name: string; lastModified: Date; size: number }> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
const stream = minioClient.listObjects(BUCKET, prefix, true);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.on("data", (obj) => {
|
||||||
|
if (obj.name) {
|
||||||
|
results.push({
|
||||||
|
name: obj.name,
|
||||||
|
lastModified: obj.lastModified ?? new Date(),
|
||||||
|
size: obj.size ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on("end", () => resolve(results));
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a presigned download URL (7 day expiry).
|
||||||
|
*/
|
||||||
|
export async function getPresignedUrl(
|
||||||
|
minioPath: string,
|
||||||
|
expirySeconds = 7 * 24 * 3600,
|
||||||
|
): Promise<string> {
|
||||||
|
return minioClient.presignedGetObject(BUCKET, minioPath, expirySeconds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* ANCPI ePay integration types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Config ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type EpayConfig = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
baseUrl: string; // https://epay.ancpi.ro/epay
|
||||||
|
loginUrl: string; // https://oassl.ancpi.ro/openam/UI/Login
|
||||||
|
defaultSolicitantId: string; // "14452"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── API Responses ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type EpayCartItem = {
|
||||||
|
id: number; // basketRowId
|
||||||
|
productId: number;
|
||||||
|
sku?: string;
|
||||||
|
custom2?: string;
|
||||||
|
custom5?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpayCartResponse = {
|
||||||
|
items: EpayCartItem[];
|
||||||
|
numberOfItems: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpaySearchResult = {
|
||||||
|
countyId: string;
|
||||||
|
immovableId: string;
|
||||||
|
immovableTypeCode: string; // T=Teren, C=Constructie, A=Apartament
|
||||||
|
electronicIdentifier: string;
|
||||||
|
previousCadNo: string | null;
|
||||||
|
topographicNo: string | null;
|
||||||
|
measureadArea: string | null; // typo is in ePay API
|
||||||
|
legalArea: string | null;
|
||||||
|
status: string; // "Activa"
|
||||||
|
landBookType: { code: string };
|
||||||
|
hasGraphics: boolean;
|
||||||
|
address: string;
|
||||||
|
previousLandBookNo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpayUatEntry = {
|
||||||
|
id: number; // real ePay UAT ID (NOT simple index)
|
||||||
|
value: string; // UAT name
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpaySolutionDoc = {
|
||||||
|
idDocument: number;
|
||||||
|
idTipDocument: number | null;
|
||||||
|
nume: string; // "Extras_Informare_65297.pdf"
|
||||||
|
numar: string | null;
|
||||||
|
serie: string | null;
|
||||||
|
dataDocument: string; // "2026-03-22"
|
||||||
|
contentType: string;
|
||||||
|
linkDownload: string;
|
||||||
|
downloadValabil: boolean;
|
||||||
|
valabilNelimitat: boolean;
|
||||||
|
zileValabilitateDownload: number;
|
||||||
|
transactionId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpayOrderStatus = {
|
||||||
|
orderId: string;
|
||||||
|
status: string; // "Receptionata" | "In curs de procesare" | "Finalizata" | "Anulata"
|
||||||
|
documents: EpaySolutionDoc[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Domain Types ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type CfExtractStatus =
|
||||||
|
| "pending"
|
||||||
|
| "queued"
|
||||||
|
| "cart"
|
||||||
|
| "searching"
|
||||||
|
| "ordering"
|
||||||
|
| "polling"
|
||||||
|
| "downloading"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
export type CfExtractCreateInput = {
|
||||||
|
nrCadastral: string;
|
||||||
|
nrCF?: string;
|
||||||
|
siruta?: string;
|
||||||
|
judetIndex: number;
|
||||||
|
judetName: string;
|
||||||
|
uatId: number;
|
||||||
|
uatName: string;
|
||||||
|
gisFeatureId?: string;
|
||||||
|
prodId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderMetadata = {
|
||||||
|
basketRowId: number;
|
||||||
|
judetIndex: number;
|
||||||
|
uatId: number;
|
||||||
|
nrCF: string;
|
||||||
|
nrCadastral: string;
|
||||||
|
solicitantId: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user