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:
@@ -6,6 +6,13 @@ import {
|
||||
releaseIssuedOrderNumber,
|
||||
} from "../services/numbering.service";
|
||||
import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema";
|
||||
import {
|
||||
computeIssuedOrderTotals,
|
||||
createIssuedOrder,
|
||||
getIssuedOrder,
|
||||
updateIssuedOrder,
|
||||
deleteIssuedOrder,
|
||||
} from "../services/issued-orders.service";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
|
||||
@@ -55,3 +62,137 @@ describe("CreateIssuedOrderSchema", () => {
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const createdIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of createdCustomerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
createdIds.length = 0;
|
||||
createdCustomerIds.length = 0;
|
||||
});
|
||||
|
||||
async function makeCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "Dodavatel s.r.o." },
|
||||
});
|
||||
createdCustomerIds.push(c.id);
|
||||
return c;
|
||||
}
|
||||
|
||||
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
||||
it("adds VAT on top of net, rounded per line", () => {
|
||||
const t = computeIssuedOrderTotals(
|
||||
[
|
||||
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
||||
],
|
||||
true,
|
||||
21,
|
||||
);
|
||||
expect(t.subtotal).toBe(250);
|
||||
expect(t.vat_amount).toBe(48);
|
||||
expect(t.total).toBe(298);
|
||||
});
|
||||
|
||||
it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => {
|
||||
const t = computeIssuedOrderTotals(
|
||||
[{ quantity: 3, unit_price: 100, vat_rate: 21 }],
|
||||
false,
|
||||
21,
|
||||
);
|
||||
expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 });
|
||||
});
|
||||
|
||||
it("falls back to the document rate when a line rate is null", () => {
|
||||
const t = computeIssuedOrderTotals(
|
||||
[{ quantity: 1, unit_price: 100, vat_rate: null }],
|
||||
true,
|
||||
15,
|
||||
);
|
||||
expect(t.vat_amount).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createIssuedOrder", () => {
|
||||
it("assigns a PO number, defaults to draft, stores items", async () => {
|
||||
const c = await makeCustomer();
|
||||
const order = await createIssuedOrder({
|
||||
customer_id: c.id,
|
||||
items: [
|
||||
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIds.push(order.id);
|
||||
expect(order.po_number).toBeTruthy();
|
||||
expect(order.status).toBe("draft");
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: order.id },
|
||||
});
|
||||
expect(items.length).toBe(1);
|
||||
expect(Number(items[0].unit_price)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateIssuedOrder status transitions", () => {
|
||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
||||
const order = await createIssuedOrder({});
|
||||
createdIds.push(order.id);
|
||||
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||
expect("error" in ok).toBe(false);
|
||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
||||
expect("error" in bad && bad.error).toBe("invalid_transition");
|
||||
});
|
||||
|
||||
it("locks items once confirmed", async () => {
|
||||
const order = await createIssuedOrder({
|
||||
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
||||
});
|
||||
createdIds.push(order.id);
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||
await updateIssuedOrder(order.id, {
|
||||
items: [{ description: "B", quantity: 9, unit_price: 99 }],
|
||||
});
|
||||
const items = await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: order.id },
|
||||
});
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].description).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssuedOrder", () => {
|
||||
it("returns customer_name and valid_transitions", async () => {
|
||||
const c = await makeCustomer();
|
||||
const order = await createIssuedOrder({ customer_id: c.id });
|
||||
createdIds.push(order.id);
|
||||
const detail = await getIssuedOrder(order.id);
|
||||
expect(detail?.customer_name).toBe("Dodavatel s.r.o.");
|
||||
expect(detail?.valid_transitions).toContain("sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteIssuedOrder", () => {
|
||||
it("deletes, cascades items, frees the latest number", async () => {
|
||||
const order = await createIssuedOrder({
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
||||
});
|
||||
const before = order.po_number;
|
||||
await deleteIssuedOrder(order.id);
|
||||
expect(
|
||||
await prisma.issued_orders.findUnique({ where: { id: order.id } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
(
|
||||
await prisma.issued_order_items.findMany({
|
||||
where: { issued_order_id: order.id },
|
||||
})
|
||||
).length,
|
||||
).toBe(0);
|
||||
expect((await previewIssuedOrderNumber()).number).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
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