import { Prisma, type $Enums } from "@prisma/client"; import prisma from "../config/database"; import { utcMidnightOfLocalDay } from "../utils/date"; import { generateIssuedOrderNumber, previewIssuedOrderNumber, releaseIssuedOrderNumber, assignIssuedOrderNumber, isIssuedOrderNumberTaken, } from "./numbering.service"; import { nasOrdersManager } from "./nas-financials-manager"; import { encodeSelectedCustomFields, parseSelectedCustomFields, } from "../utils/custom-fields"; export interface IssuedOrderItemInput { description?: string | null; item_description?: string | null; quantity?: number | string | null; unit?: string | null; unit_price?: number | string | null; position?: number | null; } export interface IssuedOrderSectionInput { title?: string | null; title_cz?: string | null; content?: string | null; position?: number | null; } export interface IssuedOrderInput { po_number?: string | number | null; supplier_id?: number | string | null; status?: string; currency?: string; exchange_rate?: number | string | null; order_date?: string | null; delivery_date?: string | null; language?: string; order_text?: string | null; internal_notes?: string | null; items?: IssuedOrderItemInput[]; sections?: IssuedOrderSectionInput[]; [key: string]: unknown; } interface IssuedOrderFilterParams { search?: string; status?: string; supplier_id?: number; month?: number; year?: number; } interface ListIssuedOrdersParams extends IssuedOrderFilterParams { page: number; limit: number; skip: number; sort: string; order: string; } export interface CurrencyAmount { amount: number; currency: string; } /** * Shared `where` builder for the issued-order list and the per-currency stats * aggregation, so both stay in sync. month/year filter on order_date (matching * the list). */ function buildIssuedOrderWhere( params: IssuedOrderFilterParams, ): Record { const { search, status, supplier_id, month, year } = params; const where: Record = {}; if (status) where.status = status; if (supplier_id) where.supplier_id = supplier_id; if (search) { where.OR = [ { po_number: { contains: search } }, { suppliers: { name: { contains: search } } }, { suppliers: { ico: { contains: search } } }, ]; } if (month && year) { // order_date is @db.Date: Prisma compares by UTC date part, so the month // boundaries must be UTC midnights. Local midnights shifted the window a // day back (included prev-month's last day, dropped this month's last day). const from = new Date(Date.UTC(year, month - 1, 1)); const to = new Date(Date.UTC(year, month, 1)); where.order_date = { gte: from, lt: to }; } return where; } const VALID_TRANSITIONS: Record = { draft: ["sent", "cancelled"], sent: ["confirmed", "cancelled"], confirmed: ["completed", "cancelled"], completed: [], cancelled: [], }; const ALLOWED_SORT_FIELDS = [ "id", "po_number", "status", "order_date", "created_at", "currency", ]; /** * Non-status update fields — used by the editable-state guard so a field edit * on a confirmed/completed/cancelled order is rejected EXPLICITLY (it used to * be silently dropped). `po_number` is stripped by the schema and the service * never reads it on update. */ const NON_STATUS_UPDATE_FIELDS = [ "supplier_id", "currency", "exchange_rate", "order_date", "delivery_date", "language", "order_text", "internal_notes", "items", "sections", ] as const; /** * NET-only total: issued orders (PO) are not tax documents — VAT never appears * on them. The total is the plain sum of qty × unit_price, rounded to 2dp. */ export function computeIssuedOrderTotals( items: Array<{ quantity: unknown; unit_price: unknown }>, ) { let total = 0; for (const it of items) { total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); } return { total: Math.round(total * 100) / 100 }; } export async function listIssuedOrders(params: ListIssuedOrdersParams) { const { page, limit, skip, sort, order } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const where = buildIssuedOrderWhere(params); // Normalize the direction so an unexpected value can't reach Prisma at runtime. const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc"; // Tiebreak on id so same-day order_date rows sort deterministically. const orderBy: Record[] = [ { [sortField]: dir }, { id: dir }, ]; const [rows, total] = await Promise.all([ prisma.issued_orders.findMany({ where, skip, take: limit, orderBy, include: { suppliers: { select: { id: true, name: true } }, issued_order_items: true, }, }), prisma.issued_orders.count({ where }), ]); const enriched = rows.map((o) => { const totals = computeIssuedOrderTotals(o.issued_order_items); const { issued_order_items, ...rest } = o; return { ...rest, items: issued_order_items, supplier_name: o.suppliers?.name || null, ...totals, }; }); return { data: enriched, total, page, limit }; } /** * Sum issued-order NET total per currency across the WHOLE filtered set * (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list, * and `computeIssuedOrderTotals` so per-order math matches the list/detail. * Currency defaults to "CZK" when the column is null. Returns one entry per * currency, rounded to 2dp, zero/empty totals dropped. */ export async function getIssuedOrderTotals( params: IssuedOrderFilterParams, ): Promise<{ totals: CurrencyAmount[] }> { const where = buildIssuedOrderWhere(params); const rows = await prisma.issued_orders.findMany({ where, include: { issued_order_items: true }, }); const byCurrency: Record = {}; for (const o of rows) { const { total } = computeIssuedOrderTotals(o.issued_order_items); const cur = o.currency || "CZK"; byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0); } const totals: CurrencyAmount[] = Object.entries(byCurrency) .map(([currency, amount]) => ({ currency, amount: Math.round(amount * 100) / 100, })) .filter((t) => t.amount > 0); return { totals }; } export async function getIssuedOrder(id: number) { const order = await prisma.issued_orders.findUnique({ where: { id }, include: { // Same minimal field set as the picker lookup endpoint — the full row // would expose internal notes/contact to any orders.view holder. suppliers: { select: { id: true, name: true, ico: true, dic: true, street: true, city: true, postal_code: true, country: true, }, }, issued_order_items: { orderBy: { position: "asc" } }, // id tiebreak: position can repeat, keep the order deterministic. issued_order_sections: { orderBy: [{ position: "asc" }, { id: "asc" }], }, }, }); if (!order) return null; const { issued_order_items, issued_order_sections, ...rest } = order; return { ...rest, items: issued_order_items, sections: issued_order_sections, supplier: order.suppliers, supplier_name: order.suppliers?.name || null, valid_transitions: VALID_TRANSITIONS[order.status as string] || [], selected_custom_fields: parseSelectedCustomFields( rest.selected_custom_fields, ), }; } export async function createIssuedOrder(body: IssuedOrderInput) { try { return await prisma.$transaction(async (tx) => { const status = body.status ? String(body.status) : "draft"; // Validate the referenced supplier exists BEFORE the insert — a dangling // FK would otherwise surface as a P2003 500 instead of a clean 400. const supplierId = body.supplier_id ? Number(body.supplier_id) : null; if (supplierId) { const supplier = await tx.sklad_suppliers.findUnique({ where: { id: supplierId }, select: { id: true }, }); if (!supplier) return { error: "supplier_not_found" as const }; } // Deferred numbering: a draft carries NO po_number (the column is // nullable-unique so many drafts coexist). The official number is consumed // only when the draft is finalized (assignIssuedOrderNumber on draft->sent). // A caller-supplied number, or a doc created already-finalized, numbers now. const explicit = body.po_number !== undefined && body.po_number !== null && body.po_number !== "" ? String(body.po_number) : null; // Re-check caller-supplied uniqueness INSIDE the transaction so the // check-then-insert window can't race a concurrent create (mirrors // createOffer). generateIssuedOrderNumber verifies its own numbers, // so this guards only the explicit path. if (explicit !== null && (await isIssuedOrderNumberTaken(explicit, tx))) { return { error: "po_number_taken" as const }; } const poNumber = explicit ?? (status === "draft" ? null : (await generateIssuedOrderNumber(undefined, tx)).number); const order = await tx.issued_orders.create({ data: { po_number: poNumber, supplier_id: supplierId, status: status as $Enums.issued_orders_status, currency: body.currency ? String(body.currency) : "CZK", exchange_rate: body.exchange_rate != null ? Number(body.exchange_rate) : 1.0, // order_date is @db.Date (truncated to the UTC date part) — the // "today" default must be utcMidnightOfLocalDay, or it stores // yesterday during the 00:00–02:00 Prague window. order_date: body.order_date ? new Date(String(body.order_date)) : utcMidnightOfLocalDay(), delivery_date: body.delivery_date ? new Date(String(body.delivery_date)) : null, language: body.language ? String(body.language) : "cs", order_text: body.order_text ? String(body.order_text) : null, internal_notes: body.internal_notes ? String(body.internal_notes) : null, selected_custom_fields: encodeSelectedCustomFields( body.selected_custom_fields, ), }, }); if (Array.isArray(body.items)) { await tx.issued_order_items.createMany({ data: body.items.map((item, i) => ({ issued_order_id: order.id, description: item.description ?? null, item_description: item.item_description ?? null, quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0, position: item.position ?? i, })), }); } if (Array.isArray(body.sections)) { await tx.issued_order_sections.createMany({ data: body.sections.map((s, i) => ({ issued_order_id: order.id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: s.position ?? i, })), }); } return order; }); } catch (err) { // P2002 on the po_number unique index = a concurrent insert won the race // between the in-tx check and our insert — surface the same clean // conflict token instead of a 500 (mirrors offers). if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" ) { return { error: "po_number_taken" as const }; } throw err; } } export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { const existing = await prisma.issued_orders.findUnique({ where: { id } }); if (!existing) return { error: "not_found" as const }; const currentStatus = existing.status as string; if (body.status !== undefined && body.status !== currentStatus) { const newStatus = String(body.status); const allowed = VALID_TRANSITIONS[currentStatus] || []; if (!allowed.includes(newStatus)) { return { error: "invalid_transition" as const, currentStatus, newStatus }; } } // Editable-state guard: non-status fields may change only in draft/sent. // In confirmed/completed/cancelled a field edit is REJECTED explicitly // (offers parity — it used to be silently ignored). Status-only transition // payloads (e.g. confirmed -> completed) still work. const editable = currentStatus === "draft" || currentStatus === "sent"; if ( !editable && NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined) ) { return { error: "not_editable" as const, currentStatus }; } const data: Record = { modified_at: new Date() }; if (editable) { const strFields = ["currency", "language", "order_text"]; for (const f of strFields) { if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null; } if (body.supplier_id !== undefined) { const supplierId = body.supplier_id ? Number(body.supplier_id) : null; if (supplierId) { // Same dangling-FK guard as create: 400, not a P2003 500. const supplier = await prisma.sklad_suppliers.findUnique({ where: { id: supplierId }, select: { id: true }, }); if (!supplier) return { error: "supplier_not_found" as const }; } data.supplier_id = supplierId; } if (body.exchange_rate !== undefined) data.exchange_rate = body.exchange_rate != null ? Number(body.exchange_rate) : null; if (body.order_date !== undefined) data.order_date = body.order_date ? new Date(String(body.order_date)) : null; if (body.delivery_date !== undefined) data.delivery_date = body.delivery_date ? new Date(String(body.delivery_date)) : null; if (body.internal_notes !== undefined) data.internal_notes = body.internal_notes ? String(body.internal_notes) : null; if (body.selected_custom_fields !== undefined) data.selected_custom_fields = encodeSelectedCustomFields( body.selected_custom_fields, ); } if (body.status !== undefined) data.status = String(body.status); // Finalize transition draft -> sent: consume + write the official number // ATOMICALLY with the status change, so there is never a window where the // order is "sent" but still has a null po_number. assignIssuedOrderNumber is // idempotent (won't re-number an order that already has a number). const finalizing = currentStatus === "draft" && body.status !== undefined && String(body.status) === "sent"; // ONE transaction for the header write, the finalize numbering AND the // items/sections full-replace. The items replace used to run in a SECOND // transaction after the header tx — a failure between the two left a torn // write (new header, old items). Now either everything lands or nothing. const replaceItems = editable && Array.isArray(body.items); const replaceSections = editable && Array.isArray(body.sections); let assignedNumber: string | null = null; if (finalizing || replaceItems || replaceSections) { await prisma.$transaction(async (tx) => { await tx.issued_orders.update({ where: { id }, data }); if (finalizing) assignedNumber = await assignIssuedOrderNumber(id, tx); if (replaceItems) { await tx.issued_order_items.deleteMany({ where: { issued_order_id: id }, }); await tx.issued_order_items.createMany({ data: body.items!.map((item, i) => ({ issued_order_id: id, description: item.description ?? null, item_description: item.item_description ?? null, quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0, position: item.position ?? i, })), }); } if (replaceSections) { await tx.issued_order_sections.deleteMany({ where: { issued_order_id: id }, }); await tx.issued_order_sections.createMany({ data: body.sections!.map((s, i) => ({ issued_order_id: id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: s.position ?? i, })), }); } }); } else { await prisma.issued_orders.update({ where: { id }, data }); } // newValues for the audit diff: the header fields actually written plus the // replaced collections (JSON.stringify drops the undefined = untouched keys). const newValues: Record = { ...data }; if (replaceItems) newValues.items = body.items; if (replaceSections) newValues.sections = body.sections; // Reflect the number the finalize transition just assigned (existing was read // before the assign, so its po_number is still the pre-finalize null). return { id, po_number: assignedNumber ?? existing.po_number, oldValues: existing as unknown as Record, newValues, }; } export async function deleteIssuedOrder(id: number) { const existing = await prisma.issued_orders.findUnique({ where: { id } }); if (!existing) return null; await prisma.issued_orders.delete({ where: { id } }); // Reclaim the number against the year it was allocated in (the creation year), // not the current year — a cross-year delete must release the right sequence // or the number is never freed. const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); await releaseIssuedOrderNumber(year, existing.po_number ?? undefined); // Remove the archived PDF from the NAS so the DB delete doesn't orphan it // (mirrors deleteOffer). cleanIssued sweeps every Vydané/YYYY/MM folder, so // an order whose order_date moved after archiving is still found. // Best-effort: log, never throw. if (existing.po_number && nasOrdersManager.isConfigured()) { try { nasOrdersManager.cleanIssued(existing.po_number); } catch (e) { console.error( "[issued-orders.service] NAS issued-order PDF delete failed for", existing.po_number, e, ); } } return existing; } export async function getNextIssuedOrderNumberPreview() { return previewIssuedOrderNumber(); }