344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
import type { $Enums } from "@prisma/client";
|
|
import prisma from "../config/database";
|
|
import {
|
|
generateIssuedOrderNumber,
|
|
previewIssuedOrderNumber,
|
|
releaseIssuedOrderNumber,
|
|
} from "./numbering.service";
|
|
|
|
export interface IssuedOrderItemInput {
|
|
description?: string | null;
|
|
item_description?: string | null;
|
|
quantity?: number | string | null;
|
|
unit?: string | null;
|
|
unit_price?: number | string | null;
|
|
vat_rate?: number | string | null;
|
|
position?: number | null;
|
|
}
|
|
|
|
export interface IssuedOrderInput {
|
|
po_number?: string | number | null;
|
|
customer_id?: number | string | null;
|
|
status?: string;
|
|
currency?: string;
|
|
vat_rate?: number | string | null;
|
|
apply_vat?: boolean | number | string;
|
|
exchange_rate?: number | string | null;
|
|
order_date?: string | null;
|
|
delivery_date?: string | null;
|
|
language?: string;
|
|
delivery_terms?: string | null;
|
|
payment_terms?: string | null;
|
|
issued_by?: string | null;
|
|
notes?: string | null;
|
|
internal_notes?: string | null;
|
|
items?: IssuedOrderItemInput[];
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface ListIssuedOrdersParams {
|
|
page: number;
|
|
limit: number;
|
|
skip: number;
|
|
sort: string;
|
|
order: string;
|
|
search?: string;
|
|
status?: string;
|
|
customer_id?: number;
|
|
month?: number;
|
|
year?: number;
|
|
}
|
|
|
|
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",
|
|
"currency",
|
|
];
|
|
|
|
/** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */
|
|
export function computeIssuedOrderTotals(
|
|
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
|
|
applyVat: boolean | null,
|
|
defaultVatRate: unknown,
|
|
) {
|
|
let subtotal = 0;
|
|
let vat = 0;
|
|
for (const it of items) {
|
|
const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
|
subtotal += base;
|
|
if (applyVat) {
|
|
const rate =
|
|
it.vat_rate != null && it.vat_rate !== ""
|
|
? Number(it.vat_rate)
|
|
: defaultVatRate != null
|
|
? Number(defaultVatRate)
|
|
: 21;
|
|
vat += Math.round(base * (rate / 100) * 100) / 100;
|
|
}
|
|
}
|
|
return {
|
|
subtotal: Math.round(subtotal * 100) / 100,
|
|
vat_amount: Math.round(vat * 100) / 100,
|
|
total: Math.round((subtotal + vat) * 100) / 100,
|
|
};
|
|
}
|
|
|
|
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|
const {
|
|
page,
|
|
limit,
|
|
skip,
|
|
sort,
|
|
order,
|
|
search,
|
|
status,
|
|
customer_id,
|
|
month,
|
|
year,
|
|
} = params;
|
|
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
|
|
|
const where: Record<string, unknown> = {};
|
|
if (status) where.status = status;
|
|
if (customer_id) where.customer_id = customer_id;
|
|
if (search) {
|
|
where.OR = [
|
|
{ po_number: { contains: search } },
|
|
{ customers: { name: { contains: search } } },
|
|
{ customers: { company_id: { contains: search } } },
|
|
];
|
|
}
|
|
if (month && year) {
|
|
const from = new Date(year, month - 1, 1);
|
|
const to = new Date(year, month, 1);
|
|
where.order_date = { gte: from, lt: to };
|
|
}
|
|
|
|
// 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: {
|
|
customers: { 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,
|
|
o.apply_vat,
|
|
o.vat_rate,
|
|
);
|
|
const { issued_order_items, ...rest } = o;
|
|
return {
|
|
...rest,
|
|
items: issued_order_items,
|
|
customer_name: o.customers?.name || null,
|
|
...totals,
|
|
};
|
|
});
|
|
|
|
return { data: enriched, total, page, limit };
|
|
}
|
|
|
|
export async function getIssuedOrder(id: number) {
|
|
const order = await prisma.issued_orders.findUnique({
|
|
where: { id },
|
|
include: {
|
|
customers: true,
|
|
issued_order_items: { orderBy: { position: "asc" } },
|
|
},
|
|
});
|
|
if (!order) return null;
|
|
const { issued_order_items, ...rest } = order;
|
|
return {
|
|
...rest,
|
|
items: issued_order_items,
|
|
customer: order.customers,
|
|
customer_name: order.customers?.name || null,
|
|
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
|
};
|
|
}
|
|
|
|
export async function createIssuedOrder(body: IssuedOrderInput) {
|
|
return prisma.$transaction(async (tx) => {
|
|
const poNumber =
|
|
body.po_number !== undefined &&
|
|
body.po_number !== null &&
|
|
body.po_number !== ""
|
|
? String(body.po_number)
|
|
: (await generateIssuedOrderNumber(undefined, tx)).number;
|
|
|
|
const order = await tx.issued_orders.create({
|
|
data: {
|
|
po_number: poNumber,
|
|
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
|
status: (body.status
|
|
? String(body.status)
|
|
: "draft") as $Enums.issued_orders_status,
|
|
currency: body.currency ? String(body.currency) : "CZK",
|
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
|
apply_vat: body.apply_vat !== false,
|
|
exchange_rate:
|
|
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
|
order_date: body.order_date
|
|
? new Date(String(body.order_date))
|
|
: new Date(),
|
|
delivery_date: body.delivery_date
|
|
? new Date(String(body.delivery_date))
|
|
: null,
|
|
language: body.language ? String(body.language) : "cs",
|
|
delivery_terms: body.delivery_terms
|
|
? String(body.delivery_terms)
|
|
: null,
|
|
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
|
issued_by: body.issued_by ? String(body.issued_by) : null,
|
|
notes: body.notes ? String(body.notes) : null,
|
|
internal_notes: body.internal_notes
|
|
? String(body.internal_notes)
|
|
: null,
|
|
},
|
|
});
|
|
|
|
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,
|
|
vat_rate: item.vat_rate ?? 21.0,
|
|
position: item.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
|
|
return order;
|
|
});
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
const editable = currentStatus === "draft" || currentStatus === "sent";
|
|
const data: Record<string, unknown> = { modified_at: new Date() };
|
|
|
|
if (editable) {
|
|
const strFields = [
|
|
"currency",
|
|
"language",
|
|
"delivery_terms",
|
|
"payment_terms",
|
|
"issued_by",
|
|
];
|
|
for (const f of strFields) {
|
|
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
|
}
|
|
if (body.customer_id !== undefined)
|
|
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
|
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
|
if (body.apply_vat !== undefined)
|
|
data.apply_vat =
|
|
body.apply_vat === true ||
|
|
body.apply_vat === 1 ||
|
|
body.apply_vat === "1";
|
|
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.notes !== undefined)
|
|
data.notes = body.notes ? String(body.notes) : null;
|
|
if (body.internal_notes !== undefined)
|
|
data.internal_notes = body.internal_notes
|
|
? String(body.internal_notes)
|
|
: null;
|
|
}
|
|
|
|
if (body.status !== undefined) data.status = String(body.status);
|
|
|
|
await prisma.issued_orders.update({ where: { id }, data });
|
|
|
|
if (editable && Array.isArray(body.items)) {
|
|
await prisma.$transaction(async (tx) => {
|
|
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,
|
|
vat_rate: item.vat_rate ?? 21.0,
|
|
position: item.position ?? i,
|
|
})),
|
|
});
|
|
});
|
|
}
|
|
|
|
return { id, po_number: existing.po_number };
|
|
}
|
|
|
|
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);
|
|
return existing;
|
|
}
|
|
|
|
export async function getNextIssuedOrderNumberPreview() {
|
|
return previewIssuedOrderNumber();
|
|
}
|