From 3921852eb5ae81185782b0a19646640b17e82a20 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 23 Mar 2026 00:09:52 +0200 Subject: [PATCH] feat(parcel-sync): add ANCPI ePay CF extract ordering backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker-compose.yml | 7 + prisma/schema.prisma | 47 ++ src/app/api/ancpi/credits/route.ts | 47 ++ src/app/api/ancpi/download/route.ts | 66 +++ src/app/api/ancpi/order/route.ts | 77 +++ src/app/api/ancpi/orders/route.ts | 39 ++ src/app/api/ancpi/session/route.ts | 56 ++ .../parcel-sync/services/epay-client.ts | 505 ++++++++++++++++++ .../parcel-sync/services/epay-counties.ts | 151 ++++++ .../parcel-sync/services/epay-queue.ts | 302 +++++++++++ .../services/epay-session-store.ts | 66 +++ .../parcel-sync/services/epay-storage.ts | 150 ++++++ .../parcel-sync/services/epay-types.ts | 105 ++++ 13 files changed, 1618 insertions(+) create mode 100644 src/app/api/ancpi/credits/route.ts create mode 100644 src/app/api/ancpi/download/route.ts create mode 100644 src/app/api/ancpi/order/route.ts create mode 100644 src/app/api/ancpi/orders/route.ts create mode 100644 src/app/api/ancpi/session/route.ts create mode 100644 src/modules/parcel-sync/services/epay-client.ts create mode 100644 src/modules/parcel-sync/services/epay-counties.ts create mode 100644 src/modules/parcel-sync/services/epay-queue.ts create mode 100644 src/modules/parcel-sync/services/epay-session-store.ts create mode 100644 src/modules/parcel-sync/services/epay-storage.ts create mode 100644 src/modules/parcel-sync/services/epay-types.ts diff --git a/docker-compose.yml b/docker-compose.yml index 405f881..a127287 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,13 @@ services: # eTerra ANCPI (parcel-sync module) - ETERRA_USERNAME=${ETERRA_USERNAME:-} - 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_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-} # DWG-to-DXF sidecar diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e8325e1..0f9451d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -115,3 +115,50 @@ model RegistryAudit { @@index([entryId]) @@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]) +} diff --git a/src/app/api/ancpi/credits/route.ts b/src/app/api/ancpi/credits/route.ts new file mode 100644 index 0000000..29fbbc0 --- /dev/null +++ b/src/app/api/ancpi/credits/route.ts @@ -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 }); + } +} diff --git a/src/app/api/ancpi/download/route.ts b/src/app/api/ancpi/download/route.ts new file mode 100644 index 0000000..dad58ba --- /dev/null +++ b/src/app/api/ancpi/download/route.ts @@ -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 }); + } +} diff --git a/src/app/api/ancpi/order/route.ts b/src/app/api/ancpi/order/route.ts new file mode 100644 index 0000000..bfe067c --- /dev/null +++ b/src/app/api/ancpi/order/route.ts @@ -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 }); + } +} diff --git a/src/app/api/ancpi/orders/route.ts b/src/app/api/ancpi/orders/route.ts new file mode 100644 index 0000000..29cb6ee --- /dev/null +++ b/src/app/api/ancpi/orders/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/ancpi/session/route.ts b/src/app/api/ancpi/session/route.ts new file mode 100644 index 0000000..2d6b6da --- /dev/null +++ b/src/app/api/ancpi/session/route.ts @@ -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 }); + } +} diff --git a/src/modules/parcel-sync/services/epay-client.ts b/src/modules/parcel-sync/services/epay-client.ts new file mode 100644 index 0000000..b8045f2 --- /dev/null +++ b/src/modules/parcel-sync/services/epay-client.ts @@ -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; +}; +const sessionCache = + globalStore.__epaySessionCache ?? new Map(); +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 { + 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 { + 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 { + if (this.reloginAttempted) return; + // Re-login if needed (lazy check — will be triggered on redirects) + } + + private async retryOnAuthFail(fn: () => Promise): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + }); + } +} diff --git a/src/modules/parcel-sync/services/epay-counties.ts b/src/modules/parcel-sync/services/epay-counties.ts new file mode 100644 index 0000000..8857150 --- /dev/null +++ b/src/modules/parcel-sync/services/epay-counties.ts @@ -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 = { + 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(); +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 = { + 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; +} diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts new file mode 100644 index 0000000..148f8ff --- /dev/null +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -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 { + // 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 { + 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 { + 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, +): Promise { + await prisma.cfExtract.update({ + where: { id }, + data: { status, ...extra }, + }); +} + +async function processItem(item: QueueItem): Promise { + 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 }); + } +} diff --git a/src/modules/parcel-sync/services/epay-session-store.ts b/src/modules/parcel-sync/services/epay-session-store.ts new file mode 100644 index 0000000..c9d279a --- /dev/null +++ b/src/modules/parcel-sync/services/epay-session-store.ts @@ -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(); + } +} diff --git a/src/modules/parcel-sync/services/epay-storage.ts b/src/modules/parcel-sync/services/epay-storage.ts new file mode 100644 index 0000000..7c5a8b4 --- /dev/null +++ b/src/modules/parcel-sync/services/epay-storage.ts @@ -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 { + 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 { + 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, +): 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 { + return minioClient.getObject(BUCKET, minioPath); +} + +/** + * List all stored CF extracts for a cadastral number. + */ +export async function listExtractsForParcel( + nrCadastral: string, +): Promise> { + 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 { + return minioClient.presignedGetObject(BUCKET, minioPath, expirySeconds); +} diff --git a/src/modules/parcel-sync/services/epay-types.ts b/src/modules/parcel-sync/services/epay-types.ts new file mode 100644 index 0000000..c3abfc2 --- /dev/null +++ b/src/modules/parcel-sync/services/epay-types.ts @@ -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; +};