Compare commits
7 Commits
b62132ab9e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa246c2d91 | |||
| 9b66dd6452 | |||
| ffad5bb96d | |||
| 50165d2369 | |||
| c9f1219eaa | |||
| 1c8d7ea59c | |||
| 5ad8870dc5 |
@@ -0,0 +1,64 @@
|
|||||||
|
// GET /api/ancpi/cleanup?dryRun=1 — preview what would be deleted
|
||||||
|
// POST /api/ancpi/cleanup — run the cleanup now
|
||||||
|
//
|
||||||
|
// On-demand control over the 45-day ePay extract auto-cleanup (the scheduler
|
||||||
|
// in epay-cleanup.ts runs it automatically on boot + every 24h). Useful to
|
||||||
|
// preview (dryRun) before trusting the automatic run, or to trigger it now.
|
||||||
|
//
|
||||||
|
// Auth: a staff session (requireCfAccess), OR a cron Bearer token
|
||||||
|
// (EPAY_CLEANUP_CRON_SECRET / NOTIFICATION_CRON_SECRET) so an external
|
||||||
|
// scheduler can call it. ?days overrides the retention window.
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
cleanupExpiredEpayExtracts,
|
||||||
|
EPAY_RETENTION_DAYS,
|
||||||
|
} from "@/modules/parcel-sync/services/epay-cleanup";
|
||||||
|
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function cronAuthorized(req: Request): boolean {
|
||||||
|
const secret =
|
||||||
|
process.env.EPAY_CLEANUP_CRON_SECRET ?? process.env.NOTIFICATION_CRON_SECRET;
|
||||||
|
if (!secret) return false;
|
||||||
|
const auth = req.headers.get("authorization") ?? "";
|
||||||
|
return auth === `Bearer ${secret}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorize(req: Request): Promise<boolean> {
|
||||||
|
if (cronAuthorized(req)) return true;
|
||||||
|
const access = await requireCfAccess();
|
||||||
|
return access.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDays(req: Request): number {
|
||||||
|
const raw = new URL(req.url).searchParams.get("days");
|
||||||
|
const n = raw ? parseInt(raw, 10) : NaN;
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : EPAY_RETENTION_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
if (!(await authorize(req))) {
|
||||||
|
return NextResponse.json({ error: "Neautorizat." }, { status: 401 });
|
||||||
|
}
|
||||||
|
// GET is always a dry-run (no side effects) — safe to preview from a browser.
|
||||||
|
const result = await cleanupExpiredEpayExtracts({
|
||||||
|
olderThanDays: parseDays(req),
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (!(await authorize(req))) {
|
||||||
|
return NextResponse.json({ error: "Neautorizat." }, { status: 401 });
|
||||||
|
}
|
||||||
|
const dryRun = new URL(req.url).searchParams.get("dryRun") === "1";
|
||||||
|
const result = await cleanupExpiredEpayExtracts({
|
||||||
|
olderThanDays: parseDays(req),
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||||
|
import { enrichCfLocations } from "@/modules/parcel-sync/services/cf-enrich-location";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -57,6 +58,10 @@ export async function GET(req: Request) {
|
|||||||
prisma.cfExtract.count({ where }),
|
prisma.cfExtract.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Fill missing uatName/judetName from SIRUTA (old intern rows stored an
|
||||||
|
// empty judetName) so the list shows localitate + judet for them too.
|
||||||
|
await enrichCfLocations(orders);
|
||||||
|
|
||||||
// Build statusMap for multi-cadastral queries (or single if requested)
|
// Build statusMap for multi-cadastral queries (or single if requested)
|
||||||
if (cadastralNumbers.length > 0) {
|
if (cadastralNumbers.length > 0) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getAuthSession } from "@/core/auth/require-auth";
|
import { getAuthSession } from "@/core/auth/require-auth";
|
||||||
import { gisApi, GisApiError } from "@/lib/gis-api-client";
|
import { gisApi, GisApiError } from "@/lib/gis-api-client";
|
||||||
|
import { enrichCfLocations } from "@/modules/parcel-sync/services/cf-enrich-location";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -30,9 +31,14 @@ export async function GET(request: Request) {
|
|||||||
const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0;
|
const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return NextResponse.json(
|
const data = await gisApi.enrichment.cf.list({ limit, offset, status });
|
||||||
await gisApi.enrichment.cf.list({ limit, offset, status }),
|
// gis-api returns uatName + siruta but judetName is null there — fill the
|
||||||
);
|
// county (and any missing UAT name) from the local GisUat table so the UI
|
||||||
|
// can show "localitate, jud. X" on intern rows too.
|
||||||
|
if (Array.isArray(data?.rows)) {
|
||||||
|
await enrichCfLocations(data.rows);
|
||||||
|
}
|
||||||
|
return NextResponse.json(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GisApiError) {
|
if (err instanceof GisApiError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -8,5 +8,9 @@ export async function register() {
|
|||||||
// ParcelSync auto-refresh scheduler DISABLED during GIS DB overhaul.
|
// ParcelSync auto-refresh scheduler DISABLED during GIS DB overhaul.
|
||||||
// Re-enable by uncommenting the import below once the new schema is stable.
|
// Re-enable by uncommenting the import below once the new schema is stable.
|
||||||
// await import("@/modules/parcel-sync/services/auto-refresh-scheduler");
|
// await import("@/modules/parcel-sync/services/auto-refresh-scheduler");
|
||||||
|
|
||||||
|
// ePay CF extract auto-cleanup (deletes rows + MinIO objects 45 days
|
||||||
|
// after issuance). Self-contained scheduler; safe to run every deploy.
|
||||||
|
await import("@/modules/parcel-sync/services/epay-cleanup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export interface CfExtractRow {
|
|||||||
userId: string;
|
userId: string;
|
||||||
nrCadastral: string;
|
nrCadastral: string;
|
||||||
nrCF?: string;
|
nrCF?: string;
|
||||||
|
type?: string;
|
||||||
|
siruta?: string | null;
|
||||||
|
uatName?: string | null;
|
||||||
|
judetName?: string | null;
|
||||||
status:
|
status:
|
||||||
| "pending"
|
| "pending"
|
||||||
| "queued"
|
| "queued"
|
||||||
|
|||||||
+1
-1
@@ -58,6 +58,6 @@ export const config = {
|
|||||||
* - /favicon.ico, /robots.txt, /sitemap.xml
|
* - /favicon.ico, /robots.txt, /sitemap.xml
|
||||||
* - Files with extensions (images, fonts, etc.)
|
* - Files with extensions (images, fonts, etc.)
|
||||||
*/
|
*/
|
||||||
"/((?!api/auth|api/version|api/basemap-|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
"/((?!api/auth|api/version|api/basemap-|api/notifications/digest|api/eterra/auto-refresh|api/ancpi/cleanup|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,10 +99,10 @@ function adaptLegacyRow(row: LegacyCfExtract): CfExtractRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape.
|
// Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape.
|
||||||
// gis-api currently doesn't surface uatName/siruta/judetName on the list
|
// gis-api returns siruta + uatName (judetName is null there, but the
|
||||||
// endpoint, so we leave them blank; the row type defaults to "intern"
|
// /api/cf/orders proxy fills it from the local GisUat by SIRUTA — see
|
||||||
// because gis_core's CfExtract is the cf-intern store (the cutover plan
|
// enrichCfLocations). The row type defaults to "intern" because gis's
|
||||||
// hasn't yet moved ePay writes here).
|
// CfExtract is primarily the cf-intern store.
|
||||||
export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractRecord {
|
export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -110,9 +110,9 @@ export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractReco
|
|||||||
orderId: row.orderId ?? null,
|
orderId: row.orderId ?? null,
|
||||||
nrCadastral: row.nrCadastral,
|
nrCadastral: row.nrCadastral,
|
||||||
nrCF: row.nrCF ?? null,
|
nrCF: row.nrCF ?? null,
|
||||||
siruta: null,
|
siruta: row.siruta ?? null,
|
||||||
judetName: "",
|
judetName: row.judetName ?? "",
|
||||||
uatName: "",
|
uatName: row.uatName ?? "",
|
||||||
status: row.status,
|
status: row.status,
|
||||||
epayStatus: row.epayStatus ?? null,
|
epayStatus: row.epayStatus ?? null,
|
||||||
documentName: row.documentName ?? null,
|
documentName: row.documentName ?? null,
|
||||||
@@ -167,12 +167,18 @@ async function fetchGisAc(
|
|||||||
// single timeline shows ePay + intern history together. Sort newest-
|
// single timeline shows ePay + intern history together. Sort newest-
|
||||||
// first; dedupe by id (in case the same record ever lands in both
|
// first; dedupe by id (in case the same record ever lands in both
|
||||||
// stores during the cutover migration).
|
// stores during the cutover migration).
|
||||||
|
// Cancelled rows are dead (payment refused / cleaned-up bad orders) and just
|
||||||
|
// clutter the list — hide them. failed/review stay (they're actionable).
|
||||||
|
const isListable = (r: CfExtractRecord): boolean => r.status !== "cancelled";
|
||||||
|
|
||||||
export async function fetchCfOrdersList(
|
export async function fetchCfOrdersList(
|
||||||
useGisAc: boolean,
|
useGisAc: boolean,
|
||||||
params: { limit?: number; nrCadastral?: string; status?: string } = {},
|
params: { limit?: number; nrCadastral?: string; status?: string } = {},
|
||||||
): Promise<{ orders: CfExtractRecord[]; total: number }> {
|
): Promise<{ orders: CfExtractRecord[]; total: number }> {
|
||||||
if (!useGisAc) {
|
if (!useGisAc) {
|
||||||
return fetchLegacy(params);
|
const r = await fetchLegacy(params);
|
||||||
|
const orders = r.orders.filter(isListable);
|
||||||
|
return { orders, total: orders.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull more rows from each side than the caller asked for so that the
|
// Pull more rows from each side than the caller asked for so that the
|
||||||
@@ -191,6 +197,7 @@ export async function fetchCfOrdersList(
|
|||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const dedup = merged.filter((r) => {
|
const dedup = merged.filter((r) => {
|
||||||
|
if (!isListable(r)) return false;
|
||||||
if (seen.has(r.id)) return false;
|
if (seen.has(r.id)) return false;
|
||||||
seen.add(r.id);
|
seen.add(r.id);
|
||||||
return true;
|
return true;
|
||||||
@@ -198,14 +205,8 @@ export async function fetchCfOrdersList(
|
|||||||
|
|
||||||
dedup.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
dedup.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
||||||
|
|
||||||
const total =
|
const orders = params.limit ? dedup.slice(0, params.limit) : dedup;
|
||||||
(legacy.status === "fulfilled" ? legacy.value.total : 0) +
|
return { orders, total: orders.length };
|
||||||
(gisac.status === "fulfilled" ? gisac.value.total : 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
orders: params.limit ? dedup.slice(0, params.limit) : dedup,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existence check used by the per-parcel order button. We check both
|
// Existence check used by the per-parcel order button. We check both
|
||||||
|
|||||||
@@ -141,17 +141,21 @@ export function EpayConnect({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setError("Eroare retea");
|
setError("Eroare retea");
|
||||||
shouldRetry = attempt < maxRetries;
|
shouldRetry = attempt < maxRetries;
|
||||||
|
} finally {
|
||||||
|
// ALWAYS clear the connecting spinner unless we're about to retry —
|
||||||
|
// including the `cancelled` early-returns above. Otherwise a re-run
|
||||||
|
// of this effect (e.g. when status.connected flips true) cancels the
|
||||||
|
// in-flight attempt and leaves connecting stuck true → a perpetual
|
||||||
|
// spinner on an already-connected (green) pill.
|
||||||
|
if (!shouldRetry) setConnecting(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
// Keep connecting state true during retry wait
|
|
||||||
autoConnectTimerRef.current = setTimeout(() => {
|
autoConnectTimerRef.current = setTimeout(() => {
|
||||||
void attemptConnect(attempt + 1);
|
void attemptConnect(attempt + 1);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,7 +168,11 @@ export function EpayConnect({
|
|||||||
autoConnectTimerRef.current = null;
|
autoConnectTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [triggerConnect, status.connected, connecting, fetchStatus]);
|
// `connecting` intentionally excluded: setConnecting(true) inside this
|
||||||
|
// effect would otherwise re-trigger it and cancel its own in-flight
|
||||||
|
// attempt. autoConnectAttempted (a ref) already prevents double-starts.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [triggerConnect, status.connected, fetchStatus]);
|
||||||
|
|
||||||
const disconnect = async () => {
|
const disconnect = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -202,10 +210,10 @@ export function EpayConnect({
|
|||||||
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
|
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{connecting ? (
|
{status.connected ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : status.connected ? (
|
|
||||||
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
) : connecting ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<span className="hidden sm:inline">ePay</span>
|
<span className="hidden sm:inline">ePay</span>
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export function EpayOrderButton({
|
|||||||
|
|
||||||
const [ordering, setOrdering] = useState(false);
|
const [ordering, setOrdering] = useState(false);
|
||||||
const [ordered, setOrdered] = useState(false);
|
const [ordered, setOrdered] = useState(false);
|
||||||
|
// After enqueue the order keeps processing in the background (~1–2 min on
|
||||||
|
// the legacy queue): cart → submit → poll → download. Show that instead of
|
||||||
|
// flipping straight to a misleading "valid" the instant it's queued.
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -111,7 +115,10 @@ export function EpayOrderButton({
|
|||||||
});
|
});
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setOrdered(true);
|
// Queued, not done — enter the processing state and let the poll
|
||||||
|
// effect below flip to "valid" only once the extract is actually
|
||||||
|
// ready (or surface a failure).
|
||||||
|
setProcessing(true);
|
||||||
} else {
|
} else {
|
||||||
setError(result.error ?? "Eroare comanda");
|
setError(result.error ?? "Eroare comanda");
|
||||||
}
|
}
|
||||||
@@ -119,6 +126,36 @@ export function EpayOrderButton({
|
|||||||
}
|
}
|
||||||
}, [nrCadastral, siruta, judetName, uatName, useGisAc]);
|
}, [nrCadastral, siruta, judetName, uatName, useGisAc]);
|
||||||
|
|
||||||
|
// Poll while processing: flip to "valid" only when a completed extract
|
||||||
|
// actually exists. Caps at ~3 min, then stops (the parent list refresh
|
||||||
|
// will reflect the final state).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!processing) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
const tick = async () => {
|
||||||
|
attempts += 1;
|
||||||
|
try {
|
||||||
|
const has = await fetchCfHasCompletedForCadastral(useGisAc, nrCadastral);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (has) {
|
||||||
|
setOrdered(true);
|
||||||
|
setProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* keep polling */
|
||||||
|
}
|
||||||
|
if (!cancelled && attempts >= 36) setProcessing(false); // ~3 min
|
||||||
|
};
|
||||||
|
const id = setInterval(() => void tick(), 5000);
|
||||||
|
void tick();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [processing, useGisAc, nrCadastral]);
|
||||||
|
|
||||||
// On the (future) gis.ac path, the orchestrator dispatches ePay calls
|
// On the (future) gis.ac path, the orchestrator dispatches ePay calls
|
||||||
// through a shared account pool — no personally-connected ePay session
|
// through a shared account pool — no personally-connected ePay session
|
||||||
// needed. The legacy queue (current route while the guard is on)
|
// needed. The legacy queue (current route while the guard is on)
|
||||||
@@ -143,6 +180,29 @@ export function EpayOrderButton({
|
|||||||
return tooltipText ?? "Comanda extras CF (1 credit)";
|
return tooltipText ?? "Comanda extras CF (1 credit)";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (processing) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 gap-1 px-1.5 text-yellow-600 dark:text-yellow-400"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
<span className="text-[10px]">Se procesează...</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Comanda CF este în curs (coș → plată → descărcare, ~1–2 min)
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (ordered) {
|
if (ordered) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -74,7 +74,23 @@ function isActiveStatus(status: string): boolean {
|
|||||||
|
|
||||||
type StatusStyle = { label: string; className: string; pulse?: boolean };
|
type StatusStyle = { label: string; className: string; pulse?: boolean };
|
||||||
|
|
||||||
function statusBadge(status: string, expiresAt: string | null): StatusStyle {
|
function statusBadge(
|
||||||
|
status: string,
|
||||||
|
expiresAt: string | null,
|
||||||
|
type?: "epay" | "intern",
|
||||||
|
): StatusStyle {
|
||||||
|
// Intern (copycf) extracts have NO validity term — never label them "Valid"
|
||||||
|
// or "Expirat" (which imply an ePay-style 30-day validity). The source
|
||||||
|
// badge next to this already says "intern", so the STATUS just states the
|
||||||
|
// document is available (no term).
|
||||||
|
if (type === "intern" && status === "completed") {
|
||||||
|
return {
|
||||||
|
label: "Disponibil",
|
||||||
|
className:
|
||||||
|
"bg-muted text-foreground/70 border-muted-foreground/20",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (status === "completed" && isExpired(expiresAt)) {
|
if (status === "completed" && isExpired(expiresAt)) {
|
||||||
return {
|
return {
|
||||||
label: "Expirat",
|
label: "Expirat",
|
||||||
@@ -641,7 +657,7 @@ export function EpayTab() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredOrders.map((order, idx) => {
|
{filteredOrders.map((order, idx) => {
|
||||||
const badge = statusBadge(order.status, order.expiresAt);
|
const badge = statusBadge(order.status, order.expiresAt, order.type);
|
||||||
const expired =
|
const expired =
|
||||||
order.status === "completed" && isExpired(order.expiresAt);
|
order.status === "completed" && isExpired(order.expiresAt);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Fill in uatName + judetName on CF extract rows from their SIRUTA.
|
||||||
|
//
|
||||||
|
// Intern (cf-intern) extracts — and ePay rows on the gis-api side — often
|
||||||
|
// arrive without a judetName (it's null in gis_enrichment) and sometimes
|
||||||
|
// without a uatName. Both are derivable from `siruta` via the local GisUat
|
||||||
|
// table. This batches one query for the whole page instead of N lookups.
|
||||||
|
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
|
||||||
|
type LocatableRow = {
|
||||||
|
siruta?: string | null;
|
||||||
|
uatName?: string | null;
|
||||||
|
judetName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutates rows in place: for any row with a SIRUTA whose uatName/judetName is
|
||||||
|
* blank, fill it from GisUat. Best-effort — a missing SIRUTA or a DB error
|
||||||
|
* leaves the row unchanged. Returns the same array for convenience.
|
||||||
|
*/
|
||||||
|
export async function enrichCfLocations<T extends LocatableRow>(
|
||||||
|
rows: T[],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const sirutas = Array.from(
|
||||||
|
new Set(
|
||||||
|
rows
|
||||||
|
.map((r) => (r.siruta ? String(r.siruta).trim() : ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (sirutas.length === 0) return rows;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uats = await prisma.gisUat.findMany({
|
||||||
|
where: { siruta: { in: sirutas } },
|
||||||
|
select: { siruta: true, name: true, county: true },
|
||||||
|
});
|
||||||
|
const bySiruta = new Map(uats.map((u) => [u.siruta, u]));
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const s = row.siruta ? String(row.siruta).trim() : "";
|
||||||
|
if (!s) continue;
|
||||||
|
const uat = bySiruta.get(s);
|
||||||
|
if (!uat) continue;
|
||||||
|
if (!row.uatName && uat.name) row.uatName = uat.name;
|
||||||
|
if (!row.judetName && uat.county) row.judetName = uat.county;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cf-enrich-location] lookup failed:", error);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Auto-cleanup of old ePay CF extracts.
|
||||||
|
*
|
||||||
|
* An ePay extract is valid 30 days after issuance (documentDate); after that
|
||||||
|
* it's worthless (you'd re-order). At 45 days we delete the row + its MinIO
|
||||||
|
* object to declutter the list and free storage. Only `type='epay'` rows are
|
||||||
|
* touched — the free `cf-intern` (copycf) extracts are kept.
|
||||||
|
*
|
||||||
|
* Self-contained scheduler (same pattern as auto-refresh-scheduler): started
|
||||||
|
* by importing this module from instrumentation.ts. Runs once shortly after
|
||||||
|
* boot (so it happens at least once per deploy, since redeploys reset the
|
||||||
|
* interval) and then every 24h. The cleanup is idempotent — a partial run
|
||||||
|
* (e.g. interrupted by a restart) is simply finished by the next run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { deleteCfExtractObjects } from "./epay-storage";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/** Delete ePay extracts this many days after issuance. */
|
||||||
|
export const EPAY_RETENTION_DAYS = 45;
|
||||||
|
|
||||||
|
const g = globalThis as {
|
||||||
|
__epayCleanupTimer?: ReturnType<typeof setInterval>;
|
||||||
|
__epayCleanupRunning?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CleanupResult = {
|
||||||
|
candidates: number;
|
||||||
|
rowsDeleted: number;
|
||||||
|
objectsDeleted: number;
|
||||||
|
cutoff: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and (unless dryRun) delete ePay extracts older than `olderThanDays`
|
||||||
|
* from issuance. Issuance = documentDate, falling back to createdAt for rows
|
||||||
|
* that never got a document (old failed/cancelled). Deletes the MinIO object
|
||||||
|
* first, then the DB rows.
|
||||||
|
*/
|
||||||
|
export async function cleanupExpiredEpayExtracts(opts?: {
|
||||||
|
olderThanDays?: number;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}): Promise<CleanupResult> {
|
||||||
|
const olderThanDays = opts?.olderThanDays ?? EPAY_RETENTION_DAYS;
|
||||||
|
const dryRun = opts?.dryRun ?? false;
|
||||||
|
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// COALESCE(documentDate, createdAt) < cutoff — raw query so the date logic
|
||||||
|
// runs in Postgres and uses the createdAt index where possible.
|
||||||
|
const rows = (await prisma.$queryRaw`
|
||||||
|
SELECT id, "minioPath"
|
||||||
|
FROM "CfExtract"
|
||||||
|
WHERE type = 'epay'
|
||||||
|
AND COALESCE("documentDate", "createdAt") < ${cutoff}
|
||||||
|
`) as Array<{ id: string; minioPath: string | null }>;
|
||||||
|
|
||||||
|
const result: CleanupResult = {
|
||||||
|
candidates: rows.length,
|
||||||
|
rowsDeleted: 0,
|
||||||
|
objectsDeleted: 0,
|
||||||
|
cutoff: cutoff.toISOString(),
|
||||||
|
dryRun,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rows.length === 0 || dryRun) {
|
||||||
|
console.log(
|
||||||
|
`[epay-cleanup] ${dryRun ? "(dry-run) " : ""}${rows.length} ePay extract(s) older than ${olderThanDays}d (cutoff ${cutoff.toISOString().slice(0, 10)})`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete MinIO objects first (best-effort) so a deleted DB row never leaves
|
||||||
|
// an orphan file.
|
||||||
|
const paths = rows.map((r) => r.minioPath).filter((p): p is string => !!p);
|
||||||
|
result.objectsDeleted = await deleteCfExtractObjects(paths);
|
||||||
|
|
||||||
|
const del = await prisma.cfExtract.deleteMany({
|
||||||
|
where: { id: { in: rows.map((r) => r.id) } },
|
||||||
|
});
|
||||||
|
result.rowsDeleted = del.count;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[epay-cleanup] deleted ${result.rowsDeleted} row(s) + ${result.objectsDeleted} object(s) older than ${olderThanDays}d`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the cleanup once, guarded against overlap. Never throws. */
|
||||||
|
async function runCleanupSafely(): Promise<void> {
|
||||||
|
if (g.__epayCleanupRunning) return;
|
||||||
|
g.__epayCleanupRunning = true;
|
||||||
|
try {
|
||||||
|
await cleanupExpiredEpayExtracts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[epay-cleanup] run failed:", error);
|
||||||
|
} finally {
|
||||||
|
g.__epayCleanupRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const BOOT_DELAY_MS = 90_000; // let the app finish starting first
|
||||||
|
|
||||||
|
// Start the scheduler (idempotent — one per process).
|
||||||
|
if (!g.__epayCleanupTimer) {
|
||||||
|
setTimeout(() => void runCleanupSafely(), BOOT_DELAY_MS);
|
||||||
|
g.__epayCleanupTimer = setInterval(() => void runCleanupSafely(), ONE_DAY_MS);
|
||||||
|
console.log(
|
||||||
|
`[epay-cleanup] scheduler armed (retention ${EPAY_RETENTION_DAYS}d, boot run in ${BOOT_DELAY_MS / 1000}s, then every 24h)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,9 @@ const POLL_MAX_ATTEMPTS = 40;
|
|||||||
// ShowOrderDetails page size — large enough to fetch any realistic batch in
|
// ShowOrderDetails page size — large enough to fetch any realistic batch in
|
||||||
// one request (see getOrderStatus / QW4).
|
// one request (see getOrderStatus / QW4).
|
||||||
const ORDER_PAGE_SIZE = 50;
|
const ORDER_PAGE_SIZE = 50;
|
||||||
|
// Document download retry (transient ANCPI 5xx / timeout / error-page).
|
||||||
|
const DOWNLOAD_MAX_ATTEMPTS = 4;
|
||||||
|
const DOWNLOAD_RETRY_DELAY_MS = 3_000; // linear backoff: 3s, 6s, 9s
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Session cache */
|
/* Session cache */
|
||||||
@@ -233,9 +236,11 @@ export class EpayClient {
|
|||||||
|
|
||||||
const data = response.data as EpayCartResponse;
|
const data = response.data as EpayCartResponse;
|
||||||
const items = Array.isArray(data?.items) ? data.items : [];
|
const items = Array.isArray(data?.items) ? data.items : [];
|
||||||
// The freshly added row is the one we didn't know about; ePay returns
|
// ePay returns the full cart NEWEST-FIRST, so the just-added row is
|
||||||
// the full cart in `items`, newest typically last. Be defensive.
|
// items[0]. (Taking items[last] broke 2+ item batches: every add
|
||||||
const added = items[items.length - 1] ?? items[0];
|
// reported the OLDEST row's id, so two rows collapsed onto one
|
||||||
|
// basketRowId and metadata was saved to the wrong row — 2026-06-05.)
|
||||||
|
const added = items[0];
|
||||||
if (!added?.id) {
|
if (!added?.id) {
|
||||||
throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`);
|
throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
@@ -604,28 +609,34 @@ export class EpayClient {
|
|||||||
});
|
});
|
||||||
const html = String(response.data ?? "");
|
const html = String(response.data ?? "");
|
||||||
|
|
||||||
// Find ALL orderIds on the page
|
// ePay order numbers are sequential, so a genuinely NEW order is always
|
||||||
|
// numerically GREATER than the latest order that existed before submit.
|
||||||
|
// Requiring oid > previousOrderId is what stops us from adopting an
|
||||||
|
// unrelated OLD order when our submit didn't actually create one — the
|
||||||
|
// "!= previousOrderId" check alone let an older id through (2026-06-05:
|
||||||
|
// a new batch grabbed yesterday's order 10009605 and attached its PDFs).
|
||||||
|
const prevNum = previousOrderId ? Number(previousOrderId) : 0;
|
||||||
|
const isGenuinelyNew = (oid: string): boolean =>
|
||||||
|
!!oid &&
|
||||||
|
oid !== previousOrderId &&
|
||||||
|
!knownOrderIds?.has(oid) &&
|
||||||
|
(!Number.isFinite(prevNum) || prevNum === 0 || Number(oid) > prevNum);
|
||||||
|
|
||||||
|
// Find ALL orderIds on the page; take the highest genuinely-new one.
|
||||||
const allMatches = html.matchAll(/ShowOrderDetails\.action\?orderId=(\d+)/g);
|
const allMatches = html.matchAll(/ShowOrderDetails\.action\?orderId=(\d+)/g);
|
||||||
|
let best = "";
|
||||||
for (const m of allMatches) {
|
for (const m of allMatches) {
|
||||||
const oid = m[1] ?? "";
|
const oid = m[1] ?? "";
|
||||||
if (!oid) continue;
|
if (!isGenuinelyNew(oid)) continue;
|
||||||
if (oid === previousOrderId) continue;
|
if (!best || Number(oid) > Number(best)) best = oid;
|
||||||
if (knownOrderIds?.has(oid)) continue;
|
}
|
||||||
console.log(`[epay] New orderId: ${oid}`);
|
if (best) {
|
||||||
return oid;
|
console.log(`[epay] New orderId: ${best}`);
|
||||||
}
|
return best;
|
||||||
|
|
||||||
// If no new orderId found, the latest one might be it (first order) —
|
|
||||||
// but NEVER adopt the previous/known order: after a submit that timed
|
|
||||||
// out without creating anything, returning the stale id would attach
|
|
||||||
// the wrong order and download its old documents.
|
|
||||||
const latest = html.match(/ShowOrderDetails\.action\?orderId=(\d+)/);
|
|
||||||
const latestId = latest?.[1];
|
|
||||||
if (latestId && latestId !== previousOrderId && !knownOrderIds?.has(latestId)) {
|
|
||||||
console.log(`[epay] Using latest orderId: ${latestId}`);
|
|
||||||
return latestId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No genuinely-new order on the dashboard → the submit created nothing.
|
||||||
|
// Fail (recoverable) rather than adopting a stale/previous/known order.
|
||||||
throw new Error("Could not determine orderId after checkout");
|
throw new Error("Could not determine orderId after checkout");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,11 +782,21 @@ export class EpayClient {
|
|||||||
onProgress?: (attempt: number, status: string) => void,
|
onProgress?: (attempt: number, status: string) => void,
|
||||||
): Promise<EpayOrderStatus> {
|
): Promise<EpayOrderStatus> {
|
||||||
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
const status = await this.getOrderStatus(orderId);
|
const status = await this.getOrderStatus(orderId);
|
||||||
if (onProgress) onProgress(attempt, status.status);
|
if (onProgress) onProgress(attempt, status.status);
|
||||||
if (["Finalizata", "Anulata", "Plata refuzata"].includes(status.status)) {
|
if (["Finalizata", "Anulata", "Plata refuzata"].includes(status.status)) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A transient ANCPI error (5xx, timeout) on ONE poll must not abort
|
||||||
|
// the whole batch — the order is paid and still being processed.
|
||||||
|
// Log and try again on the next cycle.
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(
|
||||||
|
`[epay] poll ${attempt}/${POLL_MAX_ATTEMPTS} for order ${orderId} errored (${msg}); continuing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
await sleep(POLL_INTERVAL_MS);
|
await sleep(POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
throw new Error(`ePay order ${orderId} timed out after ${POLL_MAX_ATTEMPTS} poll attempts`);
|
throw new Error(`ePay order ${orderId} timed out after ${POLL_MAX_ATTEMPTS} poll attempts`);
|
||||||
@@ -785,29 +806,57 @@ export class EpayClient {
|
|||||||
|
|
||||||
async downloadDocument(idDocument: number, typeD = 4): Promise<Buffer> {
|
async downloadDocument(idDocument: number, typeD = 4): Promise<Buffer> {
|
||||||
const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`;
|
const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`;
|
||||||
// Angular sends Content-Type: application/pdf in the REQUEST
|
let lastErr = "unknown";
|
||||||
|
|
||||||
|
// ANCPI's DownloadFile occasionally returns a transient 5xx / times out /
|
||||||
|
// hands back an error page even when the order is finalized (2026-06-05:
|
||||||
|
// 327649 got one 500, then succeeded on the very next attempt). The
|
||||||
|
// download is idempotent, so retry transient failures with backoff before
|
||||||
|
// giving up. A 4xx is treated as permanent (stop immediately).
|
||||||
|
for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
const response = await this.client.post(url, null, {
|
const response = await this.client.post(url, null, {
|
||||||
headers: { "Content-Type": "application/pdf" },
|
headers: { "Content-Type": "application/pdf" },
|
||||||
timeout: DEFAULT_TIMEOUT_MS,
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
responseType: "arraybuffer",
|
responseType: "arraybuffer",
|
||||||
|
validateStatus: () => true, // inspect status ourselves for retry
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data;
|
if (response.status >= 400) {
|
||||||
if (!data || data.length < 100) {
|
lastErr = `HTTP ${response.status}`;
|
||||||
throw new Error(`ePay download empty (${data?.length ?? 0} bytes)`);
|
if (response.status < 500) break; // client error — won't fix on retry
|
||||||
}
|
} else {
|
||||||
const buf = Buffer.from(data);
|
const buf = Buffer.from(response.data ?? Buffer.alloc(0));
|
||||||
// R2: if the ePay session expired mid-batch, DownloadFile returns the
|
if (buf.length < 100) {
|
||||||
// login/error HTML page (200 OK) instead of the PDF. Storing that as a
|
lastErr = `empty (${buf.length} bytes)`;
|
||||||
// ".pdf" silently corrupts the extract. Assert the PDF magic bytes.
|
} else if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
|
||||||
if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
|
// Not a PDF — usually a transient ANCPI error page or an expired
|
||||||
const head = buf.subarray(0, 64).toString("latin1");
|
// session. Retry; a fresh attempt often returns the real PDF.
|
||||||
throw new Error(
|
const head = buf.subarray(0, 48).toString("latin1").replace(/\s+/g, " ");
|
||||||
`ePay download not a PDF (idDocument=${idDocument}, ${buf.length} bytes, head="${head.replace(/\s+/g, " ").slice(0, 40)}") — session may have expired`,
|
lastErr = `not a PDF (head="${head.slice(0, 40)}")`;
|
||||||
);
|
} else {
|
||||||
|
if (attempt > 1) {
|
||||||
|
console.log(`[epay] download ${idDocument} recovered on attempt ${attempt}`);
|
||||||
}
|
}
|
||||||
console.log(`[epay] Downloaded document ${idDocument}: ${buf.length} bytes`);
|
console.log(`[epay] Downloaded document ${idDocument}: ${buf.length} bytes`);
|
||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network error / timeout — retryable.
|
||||||
|
lastErr = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < DOWNLOAD_MAX_ATTEMPTS) {
|
||||||
|
console.warn(
|
||||||
|
`[epay] download ${idDocument} attempt ${attempt} failed (${lastErr}); retrying in ${DOWNLOAD_RETRY_DELAY_MS * attempt}ms`,
|
||||||
|
);
|
||||||
|
await sleep(DOWNLOAD_RETRY_DELAY_MS * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`ePay download failed after ${DOWNLOAD_MAX_ATTEMPTS} attempts (idDocument=${idDocument}): ${lastErr}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -574,10 +574,10 @@ async function finalizeOrder(
|
|||||||
plans.push({ item, doc, matchedByIndex, index: next });
|
plans.push({ item, doc, matchedByIndex, index: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: download + store in parallel (bounded). Each task is fully
|
// One plan's download + store. Returns true on success. On failure it
|
||||||
// self-contained so a failure on one row doesn't abort the others. The
|
// marks the row failed and returns false so the caller can retry it.
|
||||||
// file index is pre-allocated above, so parallel stores never overwrite.
|
const downloadAndStore = async (plan: Plan): Promise<boolean> => {
|
||||||
await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async ({ item, doc, matchedByIndex, index: fileIndex }) => {
|
const { item, doc, matchedByIndex, index: fileIndex } = plan;
|
||||||
try {
|
try {
|
||||||
await updateStatus(item.extractId, "downloading", {
|
await updateStatus(item.extractId, "downloading", {
|
||||||
idDocument: doc.idDocument,
|
idDocument: doc.idDocument,
|
||||||
@@ -629,15 +629,43 @@ async function finalizeOrder(
|
|||||||
console.log(
|
console.log(
|
||||||
`[epay-queue] ${matchedByIndex ? "Review" : "Completed"}: ${item.input.nrCadastral} → ${path}`,
|
`[epay-queue] ${matchedByIndex ? "Review" : "Completed"}: ${item.input.nrCadastral} → ${path}`,
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Eroare download/stocare";
|
error instanceof Error ? error.message : "Eroare download/stocare";
|
||||||
await updateStatus(item.extractId, "failed", {
|
await updateStatus(item.extractId, "failed", {
|
||||||
errorMessage: message,
|
errorMessage: message,
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 6: download + store in parallel (bounded). Each task is fully
|
||||||
|
// self-contained so a failure on one row doesn't abort the others. The
|
||||||
|
// file index is pre-allocated above, so parallel stores never overwrite.
|
||||||
|
// downloadDocument already retries transient ANCPI errors per call; this
|
||||||
|
// adds a SECOND layer — a final sweep that re-attempts any row still
|
||||||
|
// failed (covers a longer ANCPI blip or a MinIO hiccup) with no new
|
||||||
|
// charge, since the order is already paid.
|
||||||
|
const failed: Plan[] = [];
|
||||||
|
await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async (plan) => {
|
||||||
|
const ok = await downloadAndStore(plan);
|
||||||
|
if (!ok) failed.push(plan);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[epay-queue] ${failed.length}/${plans.length} downloads failed for order ${orderId} — retry sweep in 5s...`,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
|
for (const plan of failed) {
|
||||||
|
const ok = await downloadAndStore(plan);
|
||||||
|
console.log(
|
||||||
|
`[epay-queue] retry sweep ${plan.item.input.nrCadastral}: ${ok ? "recovered" : "still failed"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update credits after successful order
|
// Update credits after successful order
|
||||||
const newCredits = await client.getCredits();
|
const newCredits = await client.getCredits();
|
||||||
updateEpayCredits(newCredits);
|
updateEpayCredits(newCredits);
|
||||||
|
|||||||
@@ -134,6 +134,35 @@ export async function getCfExtractStream(
|
|||||||
return minioClient.getObject(BUCKET, minioPath);
|
return minioClient.getObject(BUCKET, minioPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete stored CF extract objects from MinIO (best-effort, batched).
|
||||||
|
* Returns how many were removed. Used by the 45-day auto-cleanup.
|
||||||
|
*/
|
||||||
|
export async function deleteCfExtractObjects(
|
||||||
|
minioPaths: string[],
|
||||||
|
): Promise<number> {
|
||||||
|
const paths = minioPaths.filter(Boolean);
|
||||||
|
if (paths.length === 0) return 0;
|
||||||
|
try {
|
||||||
|
await minioClient.removeObjects(BUCKET, paths);
|
||||||
|
return paths.length;
|
||||||
|
} catch (error) {
|
||||||
|
// removeObjects can fail wholesale on a transport error — fall back to
|
||||||
|
// per-object deletes so one bad key doesn't block the rest.
|
||||||
|
console.warn("[epay-storage] batch delete failed, retrying per-object:", error);
|
||||||
|
let removed = 0;
|
||||||
|
for (const p of paths) {
|
||||||
|
try {
|
||||||
|
await minioClient.removeObject(BUCKET, p);
|
||||||
|
removed++;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[epay-storage] could not delete ${p}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all stored CF extracts for a cadastral number.
|
* List all stored CF extracts for a cadastral number.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user