Files
app/src/services/issued-orders.service.ts
2026-06-16 20:11:10 +02:00

551 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> {
const { search, status, supplier_id, month, year } = params;
const where: Record<string, unknown> = {};
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<string, string[]> = {
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<string, "asc" | "desc">[] = [
{ [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<string, number> = {};
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:0002: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<string, unknown> = { 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<string, unknown> = { ...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<string, unknown>,
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();
}