Compare commits
1 Commits
main
...
feat/purch
| Author | SHA1 | Date |
|---|---|---|
|
|
b96bc5dfd6 |
|
|
@ -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 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 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 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
|
### Swagger UI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -12,5 +12,6 @@
|
||||||
"20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336",
|
"20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336",
|
||||||
"20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b",
|
"20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b",
|
||||||
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e",
|
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e",
|
||||||
"20260913140451_supplier_materials": "c1f59e082ddf97443c17d52745068d9e6cbafb3d4e1088126a150f555c1cf0a1"
|
"20260913140451_supplier_materials": "c1f59e082ddf97443c17d52745068d9e6cbafb3d4e1088126a150f555c1cf0a1",
|
||||||
|
"20260913173146_purchase_orders": "1e226f158e57af94c03b8f749349d06a874a8e82c32094ebd638d0ec41dc762a"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -18,6 +18,12 @@ enum MaterialUnit {
|
||||||
METRE
|
METRE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PurchaseOrderStatus {
|
||||||
|
DRAFT
|
||||||
|
ISSUED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
model Supplier {
|
model Supplier {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
organizationId String @map("organization_id") @db.Uuid
|
organizationId String @map("organization_id") @db.Uuid
|
||||||
|
|
@ -31,6 +37,7 @@ model Supplier {
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
||||||
materials SupplierMaterial[]
|
materials SupplierMaterial[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
|
||||||
@@unique([organizationId, code])
|
@@unique([organizationId, code])
|
||||||
@@unique([id, organizationId])
|
@@unique([id, organizationId])
|
||||||
|
|
@ -50,6 +57,7 @@ model Material {
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
||||||
suppliers SupplierMaterial[]
|
suppliers SupplierMaterial[]
|
||||||
|
purchaseOrderLines PurchaseOrderLine[]
|
||||||
|
|
||||||
@@unique([organizationId, code])
|
@@unique([organizationId, code])
|
||||||
@@unique([id, organizationId])
|
@@unique([id, organizationId])
|
||||||
|
|
@ -57,6 +65,48 @@ model Material {
|
||||||
@@map("materials")
|
@@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 {
|
model SupplierMaterial {
|
||||||
supplierId String @map("supplier_id") @db.Uuid
|
supplierId String @map("supplier_id") @db.Uuid
|
||||||
materialId String @map("material_id") @db.Uuid
|
materialId String @map("material_id") @db.Uuid
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ model Organization {
|
||||||
pricingPolicies PricingPolicy[]
|
pricingPolicies PricingPolicy[]
|
||||||
suppliers Supplier[]
|
suppliers Supplier[]
|
||||||
materials Material[]
|
materials Material[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
model User {
|
model User {
|
||||||
|
|
@ -43,6 +44,7 @@ model User {
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
recoveryTokens RecoveryToken[]
|
recoveryTokens RecoveryToken[]
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
stockEntries StockLedger[]
|
stockEntries StockLedger[]
|
||||||
stockReservations StockReservation[]
|
stockReservations StockReservation[]
|
||||||
carts Cart[]
|
carts Cart[]
|
||||||
|
|
|
||||||
|
|
@ -96,4 +96,24 @@ export const COMMERCE_ERRORS = {
|
||||||
'Supplier material is unavailable',
|
'Supplier material is unavailable',
|
||||||
'Scoped supplier-material compatibility lookup failed',
|
'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;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import { DatabaseModule } from '../database/database.module';
|
||||||
import { IdentityModule } from '../identity/identity.module';
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
import { ProcurementController } from './procurement.controller';
|
import { ProcurementController } from './procurement.controller';
|
||||||
import { ProcurementStore } from './procurement.store';
|
import { ProcurementStore } from './procurement.store';
|
||||||
|
import { PurchaseOrdersController } from './purchase-orders.controller';
|
||||||
|
import { PurchaseOrderStore } from './purchase-order.store';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, IdentityModule],
|
imports: [DatabaseModule, IdentityModule],
|
||||||
providers: [ProcurementStore],
|
providers: [ProcurementStore, PurchaseOrderStore],
|
||||||
controllers: [ProcurementController],
|
controllers: [ProcurementController, PurchaseOrdersController],
|
||||||
})
|
})
|
||||||
export class ProcurementModule {}
|
export class ProcurementModule {}
|
||||||
|
|
|
||||||
|
|
@ -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')}`;
|
||||||
|
}
|
||||||
|
|
@ -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<typeof purchaseOrderSchema>;
|
||||||
|
export type PurchaseOrderQuery = z.infer<typeof purchaseOrderQuery>;
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue