From b96bc5dfd698442ca9f52af9f7f11d52e1f614d8 Mon Sep 17 00:00:00 2001 From: mihir Date: Sun, 13 Sep 2026 17:45:09 +0530 Subject: [PATCH] feat(procurement): add purchase order lifecycle --- README.md | 1 + docs/migrations.md | 1 + docs/purchase-orders-api.md | 15 ++ prisma/migration-checksums.json | 3 +- .../migration.sql | 44 ++++ prisma/procurement.prisma | 50 +++++ prisma/schema.prisma | 2 + src/common/errors/commerce-errors.ts | 20 ++ src/procurement/procurement.module.ts | 6 +- src/procurement/purchase-order-money.ts | 21 ++ src/procurement/purchase-order.schemas.ts | 31 +++ src/procurement/purchase-order.store.ts | 147 ++++++++++++++ src/procurement/purchase-orders.controller.ts | 67 ++++++ test/purchase-orders.spec.ts | 191 ++++++++++++++++++ 14 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 docs/purchase-orders-api.md create mode 100644 prisma/migrations/20260913173146_purchase_orders/migration.sql create mode 100644 src/procurement/purchase-order-money.ts create mode 100644 src/procurement/purchase-order.schemas.ts create mode 100644 src/procurement/purchase-order.store.ts create mode 100644 src/procurement/purchase-orders.controller.ts create mode 100644 test/purchase-orders.spec.ts diff --git a/README.md b/README.md index 21d4490..d6be3b5 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Phase 1C adds catalog, private addresses and inventory. See [commerce API](docs/ Phase 1D adds versioned carts, coupons, atomic checkout and private order snapshots. See [checkout API and pricing boundary](docs/checkout-api.md). Payment remains disabled until tax, shipping and payment rules are finalized. Phase 1E provides a [provider-independent blueprint and test matrix](docs/phase1e-blueprint.md), configurable pricing snapshots and an [operational outbox/API](docs/operations-api.md). No real gateway or message delivery is enabled. Phase 2A adds organization-scoped [supplier and material master data](docs/procurement-api.md). Purchase orders, receipts, QC and production remain later milestones. +Phase 2B adds [purchase-order drafts and issuance](docs/purchase-orders-api.md). Goods receipts, QC, invoices, payments and stock movements remain later milestones. ### Swagger UI diff --git a/docs/migrations.md b/docs/migrations.md index 498451a..849b952 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -15,3 +15,4 @@ Phase 1C appends four migrations after the original three: catalog/addresses, in Phase 1D appends checkout tables, reservation ownership, immutable snapshot guards and deferred order/line reconciliation. Earlier migrations are unchanged. Phase 1E adds pricing policies, immutable final-price snapshots and commerce events with leased delivery state in two further timestamped migrations. All eleven preceding migrations remain unchanged. Phase 2A appends supplier, material and supplier-material compatibility tables. It grants procurement permissions to existing system roles; custom roles must be updated through the RBAC API. +Phase 2B appends purchase-order headers and immutable line snapshots. No existing migration is modified. diff --git a/docs/purchase-orders-api.md b/docs/purchase-orders-api.md new file mode 100644 index 0000000..9293082 --- /dev/null +++ b/docs/purchase-orders-api.md @@ -0,0 +1,15 @@ +# Phase 2B: purchase orders + +Purchase orders turn active supplier-material quotes into private, organization-scoped procurement records. They are not goods receipts, invoices, stock movements, or supplier payment records. + +| Method | Route | Permission | Purpose | +| ------ | --------------------------- | ------------------ | --------------------------------------------------- | +| GET | /purchase-orders | procurement.read | Page through orders, optionally filtered by status | +| GET | /purchase-orders/:id | procurement.read | Read an order and its immutable line snapshots | +| POST | /purchase-orders | procurement.manage | Create a draft from active supplier-material quotes | +| POST | /purchase-orders/:id/issue | procurement.manage | Issue a draft order | +| POST | /purchase-orders/:id/cancel | procurement.manage | Cancel a draft order | + +Each line snapshots the material name, code, unit, supplier SKU, unit price, and line total. The header snapshots its currency and total. One currency is accepted per order, and quantities below the supplier's configured minimum are rejected. + +Orders begin as `DRAFT`. They can become `ISSUED` or `CANCELLED` exactly once, with an audit record for each transition. Issued orders cannot be changed or cancelled in this phase. Partial goods receipts will be designed separately before any stock movement is introduced. diff --git a/prisma/migration-checksums.json b/prisma/migration-checksums.json index 4632300..7a2fc56 100644 --- a/prisma/migration-checksums.json +++ b/prisma/migration-checksums.json @@ -12,5 +12,6 @@ "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336", "20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b", "20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e", - "20260913140451_supplier_materials": "c1f59e082ddf97443c17d52745068d9e6cbafb3d4e1088126a150f555c1cf0a1" + "20260913140451_supplier_materials": "c1f59e082ddf97443c17d52745068d9e6cbafb3d4e1088126a150f555c1cf0a1", + "20260913173146_purchase_orders": "1e226f158e57af94c03b8f749349d06a874a8e82c32094ebd638d0ec41dc762a" } diff --git a/prisma/migrations/20260913173146_purchase_orders/migration.sql b/prisma/migrations/20260913173146_purchase_orders/migration.sql new file mode 100644 index 0000000..a6b7568 --- /dev/null +++ b/prisma/migrations/20260913173146_purchase_orders/migration.sql @@ -0,0 +1,44 @@ +CREATE TYPE "PurchaseOrderStatus" AS ENUM ('DRAFT', 'ISSUED', 'CANCELLED'); + +CREATE TABLE "purchase_orders" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "supplier_id" UUID NOT NULL, + "created_by_id" UUID NOT NULL, + "reference" VARCHAR(20) NOT NULL, + "status" "PurchaseOrderStatus" NOT NULL DEFAULT 'DRAFT', + "currency" CHAR(3) NOT NULL, + "expected_delivery_at" DATE, + "merchandise_total" DECIMAL(12,2) NOT NULL, + "issued_at" TIMESTAMPTZ(3), + "cancelled_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "purchase_orders_pkey" PRIMARY KEY ("id") +); +CREATE TABLE "purchase_order_lines" ( + "id" UUID NOT NULL, + "purchase_order_id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "material_id" UUID NOT NULL, + "material_name" VARCHAR(160) NOT NULL, + "material_code" VARCHAR(32) NOT NULL, + "unit" "MaterialUnit" NOT NULL, + "supplier_sku" VARCHAR(64) NOT NULL, + "quantity" DECIMAL(12,3) NOT NULL, + "unit_price" DECIMAL(12,2) NOT NULL, + "line_total" DECIMAL(12,2) NOT NULL, + CONSTRAINT "purchase_order_lines_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "purchase_orders_organization_id_reference_key" ON "purchase_orders"("organization_id", "reference"); +CREATE UNIQUE INDEX "purchase_orders_id_organization_id_key" ON "purchase_orders"("id", "organization_id"); +CREATE INDEX "purchase_orders_organization_id_status_created_at_id_idx" ON "purchase_orders"("organization_id", "status", "created_at", "id"); +CREATE UNIQUE INDEX "purchase_order_lines_purchase_order_id_material_id_key" ON "purchase_order_lines"("purchase_order_id", "material_id"); +CREATE INDEX "purchase_order_lines_material_id_organization_id_idx" ON "purchase_order_lines"("material_id", "organization_id"); +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_supplier_id_organization_id_fkey" FOREIGN KEY ("supplier_id", "organization_id") REFERENCES "suppliers"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_created_by_id_organization_id_fkey" FOREIGN KEY ("created_by_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "purchase_order_lines" ADD CONSTRAINT "purchase_order_lines_purchase_order_id_organization_id_fkey" FOREIGN KEY ("purchase_order_id", "organization_id") REFERENCES "purchase_orders"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "purchase_order_lines" ADD CONSTRAINT "purchase_order_lines_material_id_organization_id_fkey" FOREIGN KEY ("material_id", "organization_id") REFERENCES "materials"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_state_dates" CHECK ((status = 'DRAFT' AND issued_at IS NULL AND cancelled_at IS NULL) OR (status = 'ISSUED' AND issued_at IS NOT NULL AND cancelled_at IS NULL) OR (status = 'CANCELLED' AND issued_at IS NULL AND cancelled_at IS NOT NULL)); +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_money" CHECK (currency IN ('INR', 'USD', 'EUR', 'GBP') AND merchandise_total >= 0); +ALTER TABLE "purchase_order_lines" ADD CONSTRAINT "purchase_order_lines_quantity" CHECK (quantity > 0 AND unit_price >= 0 AND line_total >= 0); diff --git a/prisma/procurement.prisma b/prisma/procurement.prisma index 60caadf..e1278f1 100644 --- a/prisma/procurement.prisma +++ b/prisma/procurement.prisma @@ -18,6 +18,12 @@ enum MaterialUnit { METRE } +enum PurchaseOrderStatus { + DRAFT + ISSUED + CANCELLED +} + model Supplier { id String @id @default(uuid()) @db.Uuid organizationId String @map("organization_id") @db.Uuid @@ -31,6 +37,7 @@ model Supplier { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) materials SupplierMaterial[] + purchaseOrders PurchaseOrder[] @@unique([organizationId, code]) @@unique([id, organizationId]) @@ -50,6 +57,7 @@ model Material { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) suppliers SupplierMaterial[] + purchaseOrderLines PurchaseOrderLine[] @@unique([organizationId, code]) @@unique([id, organizationId]) @@ -57,6 +65,48 @@ model Material { @@map("materials") } +model PurchaseOrder { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + supplierId String @map("supplier_id") @db.Uuid + createdById String @map("created_by_id") @db.Uuid + reference String @db.VarChar(20) + status PurchaseOrderStatus @default(DRAFT) + currency String @db.Char(3) + expectedDeliveryAt DateTime? @map("expected_delivery_at") @db.Date + merchandiseTotal Decimal @map("merchandise_total") @db.Decimal(12,2) + issuedAt DateTime? @map("issued_at") @db.Timestamptz(3) + cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + supplier Supplier @relation(fields: [supplierId, organizationId], references: [id, organizationId], onDelete: Restrict) + createdBy User @relation(fields: [createdById, organizationId], references: [id, organizationId], onDelete: Restrict) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + lines PurchaseOrderLine[] + @@unique([organizationId, reference]) + @@unique([id, organizationId]) + @@index([organizationId, status, createdAt, id]) + @@map("purchase_orders") +} + +model PurchaseOrderLine { + id String @id @default(uuid()) @db.Uuid + purchaseOrderId String @map("purchase_order_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + materialId String @map("material_id") @db.Uuid + materialName String @map("material_name") @db.VarChar(160) + materialCode String @map("material_code") @db.VarChar(32) + unit MaterialUnit + supplierSku String @map("supplier_sku") @db.VarChar(64) + quantity Decimal @db.Decimal(12,3) + unitPrice Decimal @map("unit_price") @db.Decimal(12,2) + lineTotal Decimal @map("line_total") @db.Decimal(12,2) + purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId, organizationId], references: [id, organizationId], onDelete: Restrict) + material Material @relation(fields: [materialId, organizationId], references: [id, organizationId], onDelete: Restrict) + @@unique([purchaseOrderId, materialId]) + @@index([materialId, organizationId]) + @@map("purchase_order_lines") +} + model SupplierMaterial { supplierId String @map("supplier_id") @db.Uuid materialId String @map("material_id") @db.Uuid diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 03100d7..5c62bfb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,7 @@ model Organization { pricingPolicies PricingPolicy[] suppliers Supplier[] materials Material[] + purchaseOrders PurchaseOrder[] @@map("organizations") } model User { @@ -43,6 +44,7 @@ model User { sessions Session[] recoveryTokens RecoveryToken[] addresses Address[] + purchaseOrders PurchaseOrder[] stockEntries StockLedger[] stockReservations StockReservation[] carts Cart[] diff --git a/src/common/errors/commerce-errors.ts b/src/common/errors/commerce-errors.ts index 9839f30..890a7d9 100644 --- a/src/common/errors/commerce-errors.ts +++ b/src/common/errors/commerce-errors.ts @@ -96,4 +96,24 @@ export const COMMERCE_ERRORS = { 'Supplier material is unavailable', 'Scoped supplier-material compatibility lookup failed', ], + PURCHASE_ORDER_NOT_FOUND: [ + 404, + 'Purchase order not found', + 'Scoped purchase order lookup failed', + ], + PURCHASE_ORDER_STATE: [ + 409, + 'Purchase order cannot change in its current state', + 'Purchase order lifecycle transition rejected', + ], + PURCHASE_ORDER_CURRENCY: [ + 409, + 'Purchase order materials must use one currency', + 'Supplier material quote currency mismatch', + ], + PURCHASE_ORDER_MINIMUM: [ + 409, + 'Order quantity is below the supplier minimum', + 'Supplier material minimum order quantity rejected', + ], } as const; diff --git a/src/procurement/procurement.module.ts b/src/procurement/procurement.module.ts index 26b033d..0a3c56b 100644 --- a/src/procurement/procurement.module.ts +++ b/src/procurement/procurement.module.ts @@ -3,10 +3,12 @@ import { DatabaseModule } from '../database/database.module'; import { IdentityModule } from '../identity/identity.module'; import { ProcurementController } from './procurement.controller'; import { ProcurementStore } from './procurement.store'; +import { PurchaseOrdersController } from './purchase-orders.controller'; +import { PurchaseOrderStore } from './purchase-order.store'; @Module({ imports: [DatabaseModule, IdentityModule], - providers: [ProcurementStore], - controllers: [ProcurementController], + providers: [ProcurementStore, PurchaseOrderStore], + controllers: [ProcurementController, PurchaseOrdersController], }) export class ProcurementModule {} diff --git a/src/procurement/purchase-order-money.ts b/src/procurement/purchase-order-money.ts new file mode 100644 index 0000000..c14ed08 --- /dev/null +++ b/src/procurement/purchase-order-money.ts @@ -0,0 +1,21 @@ +import { AppError } from '../common/errors/app-error'; + +function scaled(value: string, scale: number): bigint { + const [whole, fraction] = value.split('.'); + if (!whole || !fraction || fraction.length !== scale) + throw new AppError('DATA_CONSTRAINT'); + return BigInt(whole) * 10n ** BigInt(scale) + BigInt(fraction); +} +export function lineTotal(quantity: string, unitPrice: string): string { + const total = (scaled(quantity, 3) * scaled(unitPrice, 2) + 500n) / 1000n; + if (total > 99999999999999n) throw new AppError('DATA_CONSTRAINT'); + return `${total / 100n}.${(total % 100n).toString().padStart(2, '0')}`; +} +export function totalOf(lines: readonly { lineTotal: string }[]): string { + const total = lines.reduce( + (sum, line) => sum + scaled(line.lineTotal, 2), + 0n, + ); + if (total > 99999999999999n) throw new AppError('DATA_CONSTRAINT'); + return `${total / 100n}.${(total % 100n).toString().padStart(2, '0')}`; +} diff --git a/src/procurement/purchase-order.schemas.ts b/src/procurement/purchase-order.schemas.ts new file mode 100644 index 0000000..90ee991 --- /dev/null +++ b/src/procurement/purchase-order.schemas.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { pageSchema } from '../identity/identity.schemas'; + +const quantity = z + .string() + .regex(/^(0|[1-9]\d{0,8})\.\d{3}$/) + .refine((value) => value !== '0.000'); +export const purchaseOrderSchema = z + .object({ + supplierId: z.uuid(), + expectedDeliveryDate: z.iso + .date() + .optional() + .transform((value) => + value ? new Date(`${value}T00:00:00.000Z`) : undefined, + ), + lines: z + .array(z.object({ materialId: z.uuid(), quantity }).strict()) + .min(1) + .max(100) + .refine( + (lines) => + new Set(lines.map((line) => line.materialId)).size === lines.length, + ), + }) + .strict(); +export const purchaseOrderQuery = pageSchema + .extend({ status: z.enum(['DRAFT', 'ISSUED', 'CANCELLED']).optional() }) + .strict(); +export type PurchaseOrderInput = z.infer; +export type PurchaseOrderQuery = z.infer; diff --git a/src/procurement/purchase-order.store.ts b/src/procurement/purchase-order.store.ts new file mode 100644 index 0000000..8002eea --- /dev/null +++ b/src/procurement/purchase-order.store.ts @@ -0,0 +1,147 @@ +import { randomUUID } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { DatabaseService } from '../database/database.service'; +import { AccessStore } from '../identity/access.store'; +import { recordAudit } from '../identity/audit'; +import type { Principal } from '../identity/identity.types'; +import { lineTotal, totalOf } from './purchase-order-money'; +import type { + PurchaseOrderInput, + PurchaseOrderQuery, +} from './purchase-order.schemas'; + +const orderView = { + supplier: true, + lines: { orderBy: { materialCode: 'asc' as const } }, +}; +const reference = () => + `PO-${randomUUID().replaceAll('-', '').slice(0, 12).toUpperCase()}`; + +@Injectable() +export class PurchaseOrderStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + list(actor: Principal, query: PurchaseOrderQuery) { + return this.db.purchaseOrder.findMany({ + where: { + organizationId: actor.organizationId, + ...(query.status ? { status: query.status } : {}), + }, + include: orderView, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: query.limit, + skip: query.offset, + }); + } + async find(actor: Principal, id: string) { + const order = await this.db.purchaseOrder.findFirst({ + where: { id, organizationId: actor.organizationId }, + include: orderView, + }); + if (!order) throw new AppError('PURCHASE_ORDER_NOT_FOUND'); + return order; + } + create(actor: Principal, input: PurchaseOrderInput) { + return this.access.mutate(actor, 'procurement.manage', async (tx) => { + const supplier = await tx.supplier.findFirst({ + where: { + id: input.supplierId, + organizationId: actor.organizationId, + active: true, + }, + }); + if (!supplier) throw new AppError('SUPPLIER_NOT_FOUND'); + const sources = await Promise.all( + input.lines.map(async (line) => { + const source = await tx.supplierMaterial.findFirst({ + where: { + supplierId: input.supplierId, + materialId: line.materialId, + organizationId: actor.organizationId, + active: true, + }, + include: { material: true }, + }); + if (!source || !source.material.active) + throw new AppError('SUPPLIER_MATERIAL_NOT_FOUND'); + if ( + BigInt(line.quantity.replace('.', '')) < + BigInt(source.minOrderQuantity.toFixed(3).replace('.', '')) + ) + throw new AppError('PURCHASE_ORDER_MINIMUM'); + return { + source, + quantity: line.quantity, + lineTotal: lineTotal(line.quantity, source.unitPrice.toFixed(2)), + }; + }), + ); + const currency = sources[0]?.source.currency; + if ( + !currency || + sources.some(({ source }) => source.currency !== currency) + ) + throw new AppError('PURCHASE_ORDER_CURRENCY'); + const lines = sources.map(({ source, quantity, lineTotal: value }) => ({ + materialId: source.materialId, + materialName: source.material.name, + materialCode: source.material.code, + unit: source.material.unit, + supplierSku: source.supplierSku, + quantity, + unitPrice: source.unitPrice, + lineTotal: value, + })); + const order = await tx.purchaseOrder.create({ + data: { + organizationId: actor.organizationId, + supplierId: supplier.id, + createdById: actor.userId, + reference: reference(), + currency, + expectedDeliveryAt: input.expectedDeliveryDate, + merchandiseTotal: totalOf(lines), + lines: { create: lines }, + }, + include: orderView, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'purchase_order.created', + order.id, + ); + return order; + }); + } + transition(actor: Principal, id: string, action: 'issue' | 'cancel') { + return this.access.mutate(actor, 'procurement.manage', async (tx) => { + const order = await tx.purchaseOrder.findFirst({ + where: { id, organizationId: actor.organizationId }, + include: orderView, + }); + if (!order) throw new AppError('PURCHASE_ORDER_NOT_FOUND'); + if (order.status !== 'DRAFT') throw new AppError('PURCHASE_ORDER_STATE'); + const updated = await tx.purchaseOrder.update({ + where: { id }, + data: + action === 'issue' + ? { status: 'ISSUED', issuedAt: new Date() } + : { status: 'CANCELLED', cancelledAt: new Date() }, + include: orderView, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + `purchase_order.${action}d`, + id, + ); + return updated; + }); + } +} diff --git a/src/procurement/purchase-orders.controller.ts b/src/procurement/purchase-orders.controller.ts new file mode 100644 index 0000000..ff78626 --- /dev/null +++ b/src/procurement/purchase-orders.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { SchemaPipe } from '../common/validation.pipe'; +import { + CurrentPrincipal, + RequirePermission, +} from '../identity/access.decorator'; +import type { Principal } from '../identity/identity.types'; +import { PurchaseOrderStore } from './purchase-order.store'; +import { + purchaseOrderQuery, + purchaseOrderSchema, + type PurchaseOrderInput, + type PurchaseOrderQuery, +} from './purchase-order.schemas'; + +@Controller('purchase-orders') +export class PurchaseOrdersController { + constructor(private readonly orders: PurchaseOrderStore) {} + @Get() + @RequirePermission('procurement.read') + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(purchaseOrderQuery)) query: PurchaseOrderQuery, + ) { + return this.orders.list(actor, query); + } + @Get(':id') + @RequirePermission('procurement.read') + get( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.orders.find(actor, id); + } + @Post() + @RequirePermission('procurement.manage') + create( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(purchaseOrderSchema)) input: PurchaseOrderInput, + ) { + return this.orders.create(actor, input); + } + @Post(':id/issue') + @RequirePermission('procurement.manage') + issue( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.orders.transition(actor, id, 'issue'); + } + @Post(':id/cancel') + @RequirePermission('procurement.manage') + cancel( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.orders.transition(actor, id, 'cancel'); + } +} diff --git a/test/purchase-orders.spec.ts b/test/purchase-orders.spec.ts new file mode 100644 index 0000000..6ca043a --- /dev/null +++ b/test/purchase-orders.spec.ts @@ -0,0 +1,191 @@ +import { randomUUID } from 'node:crypto'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { secondActor } from './helpers/commerce'; + +describe('purchase-order API', () => { + let ctx: IdentityApp; + const auth = { type: 'bearer' as const }; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx?.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + }); + async function source(code: string, currency = 'INR') { + const supplier = await ctx + .api() + .post('/api/v1/suppliers') + .auth(ctx.token, auth) + .send({ + name: `Supplier ${code}`, + code: `S-${code}`, + contactName: 'Asha Shah', + email: `${code}@example.com`, + phone: '+919876543210', + }) + .expect(201); + const material = await ctx + .api() + .post('/api/v1/materials') + .auth(ctx.token, auth) + .send({ + name: `Wax ${code}`, + code: `M-${code}`, + kind: 'WAX', + unit: 'KILOGRAM', + }) + .expect(201); + const quote = { + supplierSku: `SKU-${code}`, + leadTimeDays: 7, + minOrderQuantity: '2.500', + unitPrice: '120.00', + currency, + }; + await ctx + .api() + .put( + `/api/v1/suppliers/${supplier.body.id}/materials/${material.body.id}`, + ) + .auth(ctx.token, auth) + .send(quote) + .expect(200); + return { supplier: supplier.body, material: material.body, quote }; + } + it('snapshots active supplier quotes and advances a draft only once', async () => { + const first = await source(randomUUID().slice(0, 8)); + const created = await ctx + .api() + .post('/api/v1/purchase-orders') + .auth(ctx.token, auth) + .send({ + supplierId: first.supplier.id, + expectedDeliveryDate: '2026-10-01', + lines: [{ materialId: first.material.id, quantity: '2.500' }], + }) + .expect(201); + expect(created.body).toMatchObject({ + status: 'DRAFT', + currency: 'INR', + merchandiseTotal: '300', + lines: [ + { + materialId: first.material.id, + supplierSku: first.quote.supplierSku, + unitPrice: '120', + lineTotal: '300', + }, + ], + }); + await ctx + .api() + .put( + `/api/v1/suppliers/${first.supplier.id}/materials/${first.material.id}`, + ) + .auth(ctx.token, auth) + .send({ ...first.quote, unitPrice: '150.00' }) + .expect(200); + const detail = await ctx + .api() + .get(`/api/v1/purchase-orders/${created.body.id}`) + .auth(ctx.token, auth) + .expect(200); + expect(detail.body.lines[0].unitPrice).toBe('120'); + const issued = await ctx + .api() + .post(`/api/v1/purchase-orders/${created.body.id}/issue`) + .auth(ctx.token, auth) + .expect(201); + expect(issued.body).toMatchObject({ status: 'ISSUED' }); + const invalid = await ctx + .api() + .post(`/api/v1/purchase-orders/${created.body.id}/cancel`) + .auth(ctx.token, auth) + .expect(409); + expect(invalid.body.code).toBe('PURCHASE_ORDER_STATE'); + expect( + await ctx.db.auditEvent.count({ + where: { + organizationId: ctx.owner.organizationId, + action: 'purchase_order.issued', + }, + }), + ).toBe(1); + const cancellable = await ctx + .api() + .post('/api/v1/purchase-orders') + .auth(ctx.token, auth) + .send({ + supplierId: first.supplier.id, + lines: [{ materialId: first.material.id, quantity: '2.500' }], + }) + .expect(201); + const cancelled = await ctx + .api() + .post(`/api/v1/purchase-orders/${cancellable.body.id}/cancel`) + .auth(ctx.token, auth) + .expect(201); + expect(cancelled.body.status).toBe('CANCELLED'); + }); + it('rejects unavailable sources, supplier minimums and mixed currencies', async () => { + const first = await source(randomUUID().slice(0, 8)); + const belowMinimum = await ctx + .api() + .post('/api/v1/purchase-orders') + .auth(ctx.token, auth) + .send({ + supplierId: first.supplier.id, + lines: [{ materialId: first.material.id, quantity: '2.499' }], + }) + .expect(409); + expect(belowMinimum.body.code).toBe('PURCHASE_ORDER_MINIMUM'); + const foreignMaterial = await source(randomUUID().slice(0, 8), 'USD'); + await ctx + .api() + .put( + `/api/v1/suppliers/${first.supplier.id}/materials/${foreignMaterial.material.id}`, + ) + .auth(ctx.token, auth) + .send({ ...foreignMaterial.quote, currency: 'USD' }) + .expect(200); + const mismatch = await ctx + .api() + .post('/api/v1/purchase-orders') + .auth(ctx.token, auth) + .send({ + supplierId: first.supplier.id, + lines: [ + { materialId: first.material.id, quantity: '2.500' }, + { materialId: foreignMaterial.material.id, quantity: '2.500' }, + ], + }) + .expect(409); + expect(mismatch.body.code).toBe('PURCHASE_ORDER_CURRENCY'); + }); + it('enforces scope and read/manage permissions', async () => { + const reader = await secondActor(ctx, true, ['procurement.read']); + await ctx + .api() + .get('/api/v1/purchase-orders') + .auth(reader.token, auth) + .expect(200); + await ctx + .api() + .post('/api/v1/purchase-orders') + .auth(reader.token, auth) + .send({ + supplierId: randomUUID(), + lines: [{ materialId: randomUUID(), quantity: '1.000' }], + }) + .expect(403); + const missing = await ctx + .api() + .get(`/api/v1/purchase-orders/${randomUUID()}`) + .auth(ctx.token, auth) + .expect(404); + expect(missing.body.code).toBe('PURCHASE_ORDER_NOT_FOUND'); + }); +});