feat(orders): issued-orders service (CRUD + totals + transitions)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
318
src/services/issued-orders.service.ts
Normal file
318
src/services/issued-orders.service.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 } =
|
||||
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 } } },
|
||||
];
|
||||
}
|
||||
|
||||
// Tiebreak on id so same-day order_date rows sort deterministically.
|
||||
const orderBy: Record<string, string>[] = [
|
||||
{ [sortField]: order },
|
||||
{ id: order },
|
||||
];
|
||||
|
||||
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 never,
|
||||
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 } });
|
||||
await releaseIssuedOrderNumber(
|
||||
new Date().getFullYear(),
|
||||
existing.po_number ?? undefined,
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function getNextIssuedOrderNumberPreview() {
|
||||
return previewIssuedOrderNumber();
|
||||
}
|
||||
Reference in New Issue
Block a user