diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index b6cdcfd..9e2fd66 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -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); + }); +}); diff --git a/src/services/issued-orders.service.ts b/src/services/issued-orders.service.ts new file mode 100644 index 0000000..e32b118 --- /dev/null +++ b/src/services/issued-orders.service.ts @@ -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 = { + 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 = {}; + 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[] = [ + { [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 = { 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(); +}