From 22a26c2e256411753a335e81c3602daf2625850a Mon Sep 17 00:00:00 2001 From: mihir Date: Sun, 13 Sep 2026 14:32:57 +0530 Subject: [PATCH] feat(procurement): add supplier and material master data --- README.md | 1 + docs/migrations.md | 1 + docs/procurement-api.md | 16 ++ prisma/migration-checksums.json | 3 +- .../migration.sql | 59 ++++++ prisma/procurement.prisma | 77 ++++++++ prisma/schema.prisma | 2 + src/app.module.ts | 2 + src/common/errors/commerce-errors.ts | 15 ++ src/identity/permissions.ts | 2 + src/procurement/procurement.controller.ts | 105 ++++++++++ src/procurement/procurement.module.ts | 12 ++ src/procurement/procurement.schemas.ts | 76 ++++++++ src/procurement/procurement.store.ts | 148 ++++++++++++++ test/procurement.spec.ts | 184 ++++++++++++++++++ 15 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 docs/procurement-api.md create mode 100644 prisma/migrations/20260913140451_supplier_materials/migration.sql create mode 100644 prisma/procurement.prisma create mode 100644 src/procurement/procurement.controller.ts create mode 100644 src/procurement/procurement.module.ts create mode 100644 src/procurement/procurement.schemas.ts create mode 100644 src/procurement/procurement.store.ts create mode 100644 test/procurement.spec.ts diff --git a/README.md b/README.md index 3462ada..21d4490 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Identity: see [API contract](docs/identity-api.md) and [setup/release guide](doc Phase 1C adds catalog, private addresses and inventory. See [commerce API](docs/commerce-api.md), [errors](docs/error-contract.md), [migrations](docs/migrations.md), [security preparation](docs/vapt-readiness.md) and [verification](docs/verification.md). 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. ### Swagger UI diff --git a/docs/migrations.md b/docs/migrations.md index daf69b1..498451a 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -14,3 +14,4 @@ Production uses `pnpm db:deploy`, then `pnpm db:status`. Never use db push in pr Phase 1C appends four migrations after the original three: catalog/addresses, inventory, commerce integrity and inventory actor scope. SQL maintains additional integrity constraints and the append-only ledger trigger. 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. diff --git a/docs/procurement-api.md b/docs/procurement-api.md new file mode 100644 index 0000000..1d30fec --- /dev/null +++ b/docs/procurement-api.md @@ -0,0 +1,16 @@ +# Phase 2A: suppliers and materials + +This phase introduces private, organization-scoped supplier and material master data. It does not create purchase orders, receipts, invoices, stock movements, production batches, or documents. + +| Method | Route | Permission | Purpose | +| -------- | -------------------------------------------- | ------------------ | --------------------------------------------------------------- | +| GET | /suppliers | procurement.read | Page through suppliers; filter with `active` and `search` | +| POST/PUT | /suppliers, /suppliers/:id | procurement.manage | Create or replace supplier records | +| GET | /materials | procurement.read | Page through materials; filter with `active` and `search` | +| POST/PUT | /materials, /materials/:id | procurement.manage | Create or replace materials | +| GET | /suppliers/:supplierId/materials | procurement.read | View the supplier's compatible materials | +| PUT | /suppliers/:supplierId/materials/:materialId | procurement.manage | Create or replace compatibility, commercial lead time and quote | + +Supplier and material codes are uppercase, organization-unique identifiers. Materials use a fixed kind and unit to avoid ambiguous procurement and BOM quantities. Compatibility records hold a supplier SKU, lead time, minimum quantity, price, currency, and active status. A future purchase order must snapshot these values rather than rely on a later edit. + +All endpoints require a session and enforce a permission again inside write transactions. Missing suppliers and materials produce distinct scoped errors. Create, update, and compatibility actions are audit-recorded without arbitrary payloads. The migration is append-only and grants the new permissions only to existing system roles; custom roles require an explicit update. diff --git a/prisma/migration-checksums.json b/prisma/migration-checksums.json index 3abdb0f..4632300 100644 --- a/prisma/migration-checksums.json +++ b/prisma/migration-checksums.json @@ -11,5 +11,6 @@ "20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7", "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336", "20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b", - "20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e" + "20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e", + "20260913140451_supplier_materials": "c1f59e082ddf97443c17d52745068d9e6cbafb3d4e1088126a150f555c1cf0a1" } diff --git a/prisma/migrations/20260913140451_supplier_materials/migration.sql b/prisma/migrations/20260913140451_supplier_materials/migration.sql new file mode 100644 index 0000000..131c5e8 --- /dev/null +++ b/prisma/migrations/20260913140451_supplier_materials/migration.sql @@ -0,0 +1,59 @@ +CREATE TYPE "MaterialKind" AS ENUM ('WAX', 'FRAGRANCE', 'WICK', 'DYE', 'VESSEL', 'PACKAGING', 'LABEL', 'OTHER'); +CREATE TYPE "MaterialUnit" AS ENUM ('GRAM', 'KILOGRAM', 'MILLILITRE', 'LITRE', 'PIECE', 'METRE'); + +CREATE TABLE "suppliers" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "name" VARCHAR(160) NOT NULL, + "code" VARCHAR(32) NOT NULL, + "contact_name" VARCHAR(160) NOT NULL, + "email" VARCHAR(254) NOT NULL, + "phone" VARCHAR(24) NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "suppliers_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "materials" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "name" VARCHAR(160) NOT NULL, + "code" VARCHAR(32) NOT NULL, + "kind" "MaterialKind" NOT NULL, + "unit" "MaterialUnit" NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "materials_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "supplier_materials" ( + "supplier_id" UUID NOT NULL, + "material_id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "supplier_sku" VARCHAR(64) NOT NULL, + "lead_time_days" INTEGER NOT NULL, + "min_order_quantity" DECIMAL(12,3) NOT NULL, + "unit_price" DECIMAL(12,2) NOT NULL, + "currency" CHAR(3) NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "supplier_materials_pkey" PRIMARY KEY ("supplier_id", "material_id") +); + +CREATE UNIQUE INDEX "suppliers_organization_id_code_key" ON "suppliers"("organization_id", "code"); +CREATE UNIQUE INDEX "suppliers_id_organization_id_key" ON "suppliers"("id", "organization_id"); +CREATE INDEX "suppliers_organization_id_active_name_id_idx" ON "suppliers"("organization_id", "active", "name", "id"); +CREATE UNIQUE INDEX "materials_organization_id_code_key" ON "materials"("organization_id", "code"); +CREATE UNIQUE INDEX "materials_id_organization_id_key" ON "materials"("id", "organization_id"); +CREATE INDEX "materials_organization_id_active_kind_name_id_idx" ON "materials"("organization_id", "active", "kind", "name", "id"); +CREATE INDEX "supplier_materials_material_id_organization_id_active_idx" ON "supplier_materials"("material_id", "organization_id", "active"); + +ALTER TABLE "suppliers" ADD CONSTRAINT "suppliers_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "materials" ADD CONSTRAINT "materials_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_supplier_id_organization_id_fkey" FOREIGN KEY ("supplier_id", "organization_id") REFERENCES "suppliers"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_material_id_organization_id_fkey" FOREIGN KEY ("material_id", "organization_id") REFERENCES "materials"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_bounds" CHECK ("lead_time_days" BETWEEN 0 AND 365 AND "min_order_quantity" > 0 AND "unit_price" >= 0 AND "currency" IN ('INR', 'USD', 'EUR', 'GBP')); +UPDATE "roles" SET "permissions" = ARRAY(SELECT DISTINCT permission FROM unnest("permissions" || ARRAY['procurement.read', 'procurement.manage']::text[]) AS permission ORDER BY permission) WHERE "is_system" = true; diff --git a/prisma/procurement.prisma b/prisma/procurement.prisma new file mode 100644 index 0000000..60caadf --- /dev/null +++ b/prisma/procurement.prisma @@ -0,0 +1,77 @@ +enum MaterialKind { + WAX + FRAGRANCE + WICK + DYE + VESSEL + PACKAGING + LABEL + OTHER +} + +enum MaterialUnit { + GRAM + KILOGRAM + MILLILITRE + LITRE + PIECE + METRE +} + +model Supplier { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + name String @db.VarChar(160) + code String @db.VarChar(32) + contactName String @map("contact_name") @db.VarChar(160) + email String @db.VarChar(254) + phone String @db.VarChar(24) + active Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + materials SupplierMaterial[] + + @@unique([organizationId, code]) + @@unique([id, organizationId]) + @@index([organizationId, active, name, id]) + @@map("suppliers") +} + +model Material { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + name String @db.VarChar(160) + code String @db.VarChar(32) + kind MaterialKind + unit MaterialUnit + active Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + suppliers SupplierMaterial[] + + @@unique([organizationId, code]) + @@unique([id, organizationId]) + @@index([organizationId, active, kind, name, id]) + @@map("materials") +} + +model SupplierMaterial { + supplierId String @map("supplier_id") @db.Uuid + materialId String @map("material_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + supplierSku String @map("supplier_sku") @db.VarChar(64) + leadTimeDays Int @map("lead_time_days") + minOrderQuantity Decimal @map("min_order_quantity") @db.Decimal(12, 3) + unitPrice Decimal @map("unit_price") @db.Decimal(12, 2) + currency String @db.Char(3) + active Boolean @default(true) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + supplier Supplier @relation(fields: [supplierId, organizationId], references: [id, organizationId], onDelete: Restrict) + material Material @relation(fields: [materialId, organizationId], references: [id, organizationId], onDelete: Restrict) + + @@id([supplierId, materialId]) + @@index([materialId, organizationId, active]) + @@map("supplier_materials") +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e2d547..03100d7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,8 @@ model Organization { warehouses Warehouse[] coupons Coupon[] pricingPolicies PricingPolicy[] + suppliers Supplier[] + materials Material[] @@map("organizations") } model User { diff --git a/src/app.module.ts b/src/app.module.ts index 80aa6cf..16996ab 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,6 +3,7 @@ import { OperationsModule } from './operations/operations.module'; import { CatalogModule } from './catalog/catalog.module'; import { AddressesModule } from './addresses/addresses.module'; import { InventoryModule } from './inventory/inventory.module'; +import { ProcurementModule } from './procurement/procurement.module'; import { Module } from '@nestjs/common'; import { EnvironmentModule } from './config/environment.module'; import { IdentityModule } from './identity/identity.module'; @@ -16,6 +17,7 @@ import { HealthModule } from './health/health.module'; CatalogModule, AddressesModule, InventoryModule, + ProcurementModule, CheckoutModule, OperationsModule, ], diff --git a/src/common/errors/commerce-errors.ts b/src/common/errors/commerce-errors.ts index 0143f92..9839f30 100644 --- a/src/common/errors/commerce-errors.ts +++ b/src/common/errors/commerce-errors.ts @@ -81,4 +81,19 @@ export const COMMERCE_ERRORS = { 'Product variant is not available for reservation', 'Non-sellable variant reservation rejected', ], + SUPPLIER_NOT_FOUND: [ + 404, + 'Supplier not found', + 'Scoped supplier lookup failed', + ], + MATERIAL_NOT_FOUND: [ + 404, + 'Material not found', + 'Scoped material lookup failed', + ], + SUPPLIER_MATERIAL_NOT_FOUND: [ + 404, + 'Supplier material is unavailable', + 'Scoped supplier-material compatibility lookup failed', + ], } as const; diff --git a/src/identity/permissions.ts b/src/identity/permissions.ts index 62a63c1..a78bf43 100644 --- a/src/identity/permissions.ts +++ b/src/identity/permissions.ts @@ -20,5 +20,7 @@ export const PERMISSIONS = [ 'inventory.adjust', 'inventory.reserve', 'inventory.commit', + 'procurement.read', + 'procurement.manage', ] as const; export type Permission = (typeof PERMISSIONS)[number]; diff --git a/src/procurement/procurement.controller.ts b/src/procurement/procurement.controller.ts new file mode 100644 index 0000000..d232d9f --- /dev/null +++ b/src/procurement/procurement.controller.ts @@ -0,0 +1,105 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Put, + 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 { ProcurementStore } from './procurement.store'; +import { + materialSchema, + procurementQuery, + supplierMaterialSchema, + supplierSchema, + type MaterialInput, + type ProcurementQuery, + type SupplierInput, + type SupplierMaterialInput, +} from './procurement.schemas'; + +@Controller() +export class ProcurementController { + constructor(private readonly procurement: ProcurementStore) {} + @Get('suppliers') + @RequirePermission('procurement.read') + listSuppliers( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(procurementQuery)) query: ProcurementQuery, + ) { + return this.procurement.listSuppliers(actor, query); + } + @Post('suppliers') + @RequirePermission('procurement.manage') + createSupplier( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(supplierSchema)) input: SupplierInput, + ) { + return this.procurement.saveSupplier(actor, input); + } + @Put('suppliers/:id') + @RequirePermission('procurement.manage') + updateSupplier( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(supplierSchema)) input: SupplierInput, + ) { + return this.procurement.saveSupplier(actor, input, id); + } + @Get('suppliers/:supplierId/materials') + @RequirePermission('procurement.read') + listSupplierMaterials( + @CurrentPrincipal() actor: Principal, + @Param('supplierId', ParseUUIDPipe) supplierId: string, + ) { + return this.procurement.listSupplierMaterials(actor, supplierId); + } + @Put('suppliers/:supplierId/materials/:materialId') + @RequirePermission('procurement.manage') + saveSupplierMaterial( + @CurrentPrincipal() actor: Principal, + @Param('supplierId', ParseUUIDPipe) supplierId: string, + @Param('materialId', ParseUUIDPipe) materialId: string, + @Body(new SchemaPipe(supplierMaterialSchema)) input: SupplierMaterialInput, + ) { + return this.procurement.saveSupplierMaterial( + actor, + supplierId, + materialId, + input, + ); + } + @Get('materials') + @RequirePermission('procurement.read') + listMaterials( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(procurementQuery)) query: ProcurementQuery, + ) { + return this.procurement.listMaterials(actor, query); + } + @Post('materials') + @RequirePermission('procurement.manage') + createMaterial( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(materialSchema)) input: MaterialInput, + ) { + return this.procurement.saveMaterial(actor, input); + } + @Put('materials/:id') + @RequirePermission('procurement.manage') + updateMaterial( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(materialSchema)) input: MaterialInput, + ) { + return this.procurement.saveMaterial(actor, input, id); + } +} diff --git a/src/procurement/procurement.module.ts b/src/procurement/procurement.module.ts new file mode 100644 index 0000000..26b033d --- /dev/null +++ b/src/procurement/procurement.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { IdentityModule } from '../identity/identity.module'; +import { ProcurementController } from './procurement.controller'; +import { ProcurementStore } from './procurement.store'; + +@Module({ + imports: [DatabaseModule, IdentityModule], + providers: [ProcurementStore], + controllers: [ProcurementController], +}) +export class ProcurementModule {} diff --git a/src/procurement/procurement.schemas.ts b/src/procurement/procurement.schemas.ts new file mode 100644 index 0000000..771989b --- /dev/null +++ b/src/procurement/procurement.schemas.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { CURRENCIES } from '../common/currency'; +import { text } from '../common/input'; +import { pageSchema } from '../identity/identity.schemas'; + +const code = z + .string() + .trim() + .toUpperCase() + .max(32) + .regex(/^[A-Z0-9][A-Z0-9_-]*$/); +const decimal = (scale: number) => + z + .string() + .regex(/^(0|[1-9]\d{0,8})\.\d+$/) + .refine((value) => value.split('.').at(-1)?.length === scale); + +export const supplierSchema = z + .object({ + name: text(160), + code, + contactName: text(160), + email: z + .email() + .max(254) + .transform((value) => value.toLowerCase()), + phone: z + .string() + .trim() + .max(24) + .regex(/^\+?[0-9 ()-]{7,24}$/), + active: z.boolean().default(true), + }) + .strict(); +export const materialSchema = z + .object({ + name: text(160), + code, + kind: z.enum([ + 'WAX', + 'FRAGRANCE', + 'WICK', + 'DYE', + 'VESSEL', + 'PACKAGING', + 'LABEL', + 'OTHER', + ]), + unit: z.enum(['GRAM', 'KILOGRAM', 'MILLILITRE', 'LITRE', 'PIECE', 'METRE']), + active: z.boolean().default(true), + }) + .strict(); +export const supplierMaterialSchema = z + .object({ + supplierSku: z + .string() + .trim() + .max(64) + .regex(/^[^<>\u0000-\u001F\u007F]*$/), + leadTimeDays: z.number().int().min(0).max(365), + minOrderQuantity: decimal(3).refine((value) => value !== '0.000'), + unitPrice: decimal(2), + currency: z.enum(CURRENCIES).default('INR'), + active: z.boolean().default(true), + }) + .strict(); +export const procurementQuery = pageSchema + .extend({ + active: z.coerce.boolean().optional(), + search: text(100).optional(), + }) + .strict(); +export type SupplierInput = z.infer; +export type MaterialInput = z.infer; +export type SupplierMaterialInput = z.infer; +export type ProcurementQuery = z.infer; diff --git a/src/procurement/procurement.store.ts b/src/procurement/procurement.store.ts new file mode 100644 index 0000000..8b3e727 --- /dev/null +++ b/src/procurement/procurement.store.ts @@ -0,0 +1,148 @@ +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 type { + MaterialInput, + ProcurementQuery, + SupplierInput, + SupplierMaterialInput, +} from './procurement.schemas'; + +@Injectable() +export class ProcurementStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + listSuppliers(actor: Principal, query: ProcurementQuery) { + return this.db.supplier.findMany({ + where: { + organizationId: actor.organizationId, + ...(query.active === undefined ? {} : { active: query.active }), + ...(query.search + ? { name: { contains: query.search, mode: 'insensitive' } } + : {}), + }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + take: query.limit, + skip: query.offset, + }); + } + listMaterials(actor: Principal, query: ProcurementQuery) { + return this.db.material.findMany({ + where: { + organizationId: actor.organizationId, + ...(query.active === undefined ? {} : { active: query.active }), + ...(query.search + ? { name: { contains: query.search, mode: 'insensitive' } } + : {}), + }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + take: query.limit, + skip: query.offset, + }); + } + saveSupplier(actor: Principal, input: SupplierInput, id?: string) { + return this.access.mutate(actor, 'procurement.manage', async (tx) => { + if ( + id && + !(await tx.supplier.findFirst({ + where: { id, organizationId: actor.organizationId }, + })) + ) + throw new AppError('SUPPLIER_NOT_FOUND'); + const supplier = id + ? await tx.supplier.update({ where: { id }, data: input }) + : await tx.supplier.create({ + data: { ...input, organizationId: actor.organizationId }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + id ? 'supplier.updated' : 'supplier.created', + supplier.id, + ); + return supplier; + }); + } + saveMaterial(actor: Principal, input: MaterialInput, id?: string) { + return this.access.mutate(actor, 'procurement.manage', async (tx) => { + if ( + id && + !(await tx.material.findFirst({ + where: { id, organizationId: actor.organizationId }, + })) + ) + throw new AppError('MATERIAL_NOT_FOUND'); + const material = id + ? await tx.material.update({ where: { id }, data: input }) + : await tx.material.create({ + data: { ...input, organizationId: actor.organizationId }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + id ? 'material.updated' : 'material.created', + material.id, + ); + return material; + }); + } + saveSupplierMaterial( + actor: Principal, + supplierId: string, + materialId: string, + input: SupplierMaterialInput, + ) { + return this.access.mutate(actor, 'procurement.manage', async (tx) => { + if ( + !(await tx.supplier.findFirst({ + where: { id: supplierId, organizationId: actor.organizationId }, + })) + ) + throw new AppError('SUPPLIER_NOT_FOUND'); + if ( + !(await tx.material.findFirst({ + where: { id: materialId, organizationId: actor.organizationId }, + })) + ) + throw new AppError('MATERIAL_NOT_FOUND'); + const relation = await tx.supplierMaterial.upsert({ + where: { supplierId_materialId: { supplierId, materialId } }, + create: { + ...input, + supplierId, + materialId, + organizationId: actor.organizationId, + }, + update: input, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'supplier_material.saved', + supplierId, + ); + return relation; + }); + } + async listSupplierMaterials(actor: Principal, supplierId: string) { + if ( + !(await this.db.supplier.findFirst({ + where: { id: supplierId, organizationId: actor.organizationId }, + })) + ) + throw new AppError('SUPPLIER_NOT_FOUND'); + return this.db.supplierMaterial.findMany({ + where: { supplierId, organizationId: actor.organizationId }, + include: { material: true }, + orderBy: { material: { name: 'asc' } }, + }); + } +} diff --git a/test/procurement.spec.ts b/test/procurement.spec.ts new file mode 100644 index 0000000..aab13b6 --- /dev/null +++ b/test/procurement.spec.ts @@ -0,0 +1,184 @@ +import { randomUUID } from 'node:crypto'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { secondActor } from './helpers/commerce'; + +describe('supplier and material API', () => { + let ctx: IdentityApp; + const auth = { type: 'bearer' as const }; + const supplierInput = { + name: 'Candle Supply Co', + code: 'CANDLE-SUPPLY', + contactName: 'Asha Shah', + email: 'asha@example.com', + phone: '+919876543210', + }; + const materialInput = { + name: 'Soy Wax', + code: 'SOY-WAX', + kind: 'WAX', + unit: 'KILOGRAM', + }; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx?.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + }); + + it('creates scoped master data and a supplier-material quote', async () => { + const supplier = await ctx + .api() + .post('/api/v1/suppliers') + .auth(ctx.token, auth) + .send(supplierInput) + .expect(201); + const material = await ctx + .api() + .post('/api/v1/materials') + .auth(ctx.token, auth) + .send(materialInput) + .expect(201); + const relation = await ctx + .api() + .put( + `/api/v1/suppliers/${supplier.body.id}/materials/${material.body.id}`, + ) + .auth(ctx.token, auth) + .send({ + supplierSku: 'SW-25', + leadTimeDays: 14, + minOrderQuantity: '25.000', + unitPrice: '320.00', + }) + .expect(200); + expect(relation.body).toMatchObject({ + supplierId: supplier.body.id, + materialId: material.body.id, + currency: 'INR', + active: true, + }); + const listed = await ctx + .api() + .get(`/api/v1/suppliers/${supplier.body.id}/materials`) + .auth(ctx.token, auth) + .expect(200); + expect(listed.body[0].material).toMatchObject({ + id: material.body.id, + name: 'Soy Wax', + }); + await ctx + .api() + .put(`/api/v1/suppliers/${supplier.body.id}`) + .auth(ctx.token, auth) + .send({ ...supplierInput, active: false }) + .expect(200); + await ctx + .api() + .put(`/api/v1/materials/${material.body.id}`) + .auth(ctx.token, auth) + .send({ ...materialInput, name: 'Refined Soy Wax' }) + .expect(200); + const activeOnly = await ctx + .api() + .get('/api/v1/suppliers?active=true') + .auth(ctx.token, auth) + .expect(200); + expect(activeOnly.body).toHaveLength(0); + expect( + await ctx.db.auditEvent.count({ + where: { + organizationId: ctx.owner.organizationId, + action: 'supplier_material.saved', + }, + }), + ).toBe(1); + }); + + it('enforces permissions, organization scope and strict validation', async () => { + const reader = await secondActor(ctx, true, ['procurement.read']); + await ctx + .api() + .get('/api/v1/suppliers') + .auth(reader.token, auth) + .expect(200); + await ctx + .api() + .post('/api/v1/suppliers') + .auth(reader.token, auth) + .send(supplierInput) + .expect(403); + const outsider = await secondActor(ctx, false, ['procurement.manage']); + await ctx + .api() + .put(`/api/v1/suppliers/${randomUUID()}`) + .auth(outsider.token, auth) + .send(supplierInput) + .expect(404); + const invalid = await ctx + .api() + .post('/api/v1/materials') + .auth(ctx.token, auth) + .send({ ...materialInput, code: '' }) + .expect(400); + expect(invalid.body.code).toBe('REQUEST_INVALID'); + const duplicateInput = { + ...materialInput, + code: `SOY-${randomUUID().slice(0, 8)}`, + }; + await ctx + .api() + .post('/api/v1/materials') + .auth(ctx.token, auth) + .send(duplicateInput) + .expect(201); + const duplicate = await ctx + .api() + .post('/api/v1/materials') + .auth(ctx.token, auth) + .send(duplicateInput) + .expect(409); + expect(duplicate.body.code).toBe('RECORD_CONFLICT'); + }); + + it('returns distinct missing-resource errors for compatibility mutations', async () => { + const missingSupplier = await ctx + .api() + .put(`/api/v1/suppliers/${randomUUID()}/materials/${randomUUID()}`) + .auth(ctx.token, auth) + .send({ + supplierSku: 'X', + leadTimeDays: 0, + minOrderQuantity: '1.000', + unitPrice: '0.00', + }) + .expect(404); + expect(missingSupplier.body.code).toBe('SUPPLIER_NOT_FOUND'); + const missingList = await ctx + .api() + .get(`/api/v1/suppliers/${randomUUID()}/materials`) + .auth(ctx.token, auth) + .expect(404); + expect(missingList.body.code).toBe('SUPPLIER_NOT_FOUND'); + const supplier = await ctx + .api() + .post('/api/v1/suppliers') + .auth(ctx.token, auth) + .send({ ...supplierInput, code: 'S-' + randomUUID().slice(0, 8) }) + .expect(201); + const missingMaterial = await ctx + .api() + .put(`/api/v1/suppliers/${supplier.body.id}/materials/${randomUUID()}`) + .auth(ctx.token, auth) + .send({ + supplierSku: 'X', + leadTimeDays: 0, + minOrderQuantity: '1.000', + unitPrice: '0.00', + }) + .expect(404); + expect(missingMaterial.body.code).toBe('MATERIAL_NOT_FOUND'); + }); +});