551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
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: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<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();
|
||
}
|