feat(procurement): add supplier and material master data
This commit is contained in:
parent
46dfae07eb
commit
22a26c2e25
|
|
@ -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 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 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.
|
||||||
|
|
||||||
### Swagger UI
|
### Swagger UI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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 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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -11,5 +11,6 @@
|
||||||
"20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7",
|
"20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,8 @@ model Organization {
|
||||||
warehouses Warehouse[]
|
warehouses Warehouse[]
|
||||||
coupons Coupon[]
|
coupons Coupon[]
|
||||||
pricingPolicies PricingPolicy[]
|
pricingPolicies PricingPolicy[]
|
||||||
|
suppliers Supplier[]
|
||||||
|
materials Material[]
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
model User {
|
model User {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { OperationsModule } from './operations/operations.module';
|
||||||
import { CatalogModule } from './catalog/catalog.module';
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { AddressesModule } from './addresses/addresses.module';
|
import { AddressesModule } from './addresses/addresses.module';
|
||||||
import { InventoryModule } from './inventory/inventory.module';
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
|
import { ProcurementModule } from './procurement/procurement.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { EnvironmentModule } from './config/environment.module';
|
import { EnvironmentModule } from './config/environment.module';
|
||||||
import { IdentityModule } from './identity/identity.module';
|
import { IdentityModule } from './identity/identity.module';
|
||||||
|
|
@ -16,6 +17,7 @@ import { HealthModule } from './health/health.module';
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
AddressesModule,
|
AddressesModule,
|
||||||
InventoryModule,
|
InventoryModule,
|
||||||
|
ProcurementModule,
|
||||||
CheckoutModule,
|
CheckoutModule,
|
||||||
OperationsModule,
|
OperationsModule,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,19 @@ export const COMMERCE_ERRORS = {
|
||||||
'Product variant is not available for reservation',
|
'Product variant is not available for reservation',
|
||||||
'Non-sellable variant reservation rejected',
|
'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;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,7 @@ export const PERMISSIONS = [
|
||||||
'inventory.adjust',
|
'inventory.adjust',
|
||||||
'inventory.reserve',
|
'inventory.reserve',
|
||||||
'inventory.commit',
|
'inventory.commit',
|
||||||
|
'procurement.read',
|
||||||
|
'procurement.manage',
|
||||||
] as const;
|
] as const;
|
||||||
export type Permission = (typeof PERMISSIONS)[number];
|
export type Permission = (typeof PERMISSIONS)[number];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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<typeof supplierSchema>;
|
||||||
|
export type MaterialInput = z.infer<typeof materialSchema>;
|
||||||
|
export type SupplierMaterialInput = z.infer<typeof supplierMaterialSchema>;
|
||||||
|
export type ProcurementQuery = z.infer<typeof procurementQuery>;
|
||||||
|
|
@ -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' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: '<unsafe>' })
|
||||||
|
.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue