Compare commits
3 Commits
feat/swagg
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
d22aa78b6d | |
|
|
22a26c2e25 | |
|
|
46dfae07eb |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336",
|
||||
"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[]
|
||||
coupons Coupon[]
|
||||
pricingPolicies PricingPolicy[]
|
||||
suppliers Supplier[]
|
||||
materials Material[]
|
||||
@@map("organizations")
|
||||
}
|
||||
model User {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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