From a742f701e73155953fda0eb7dde3f37b03eb55b7 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 10:31:44 +0800 Subject: [PATCH] feat(04-01): InventoryService, API routes, migration, and 13 passing tests - InventoryService: registerItem, recordMovement, getStockLevels, getItemMovements, listItems - RECEIVED movements auto-post JE (DR 1200 Equipment Inventory, CR 2010 AP) - Stock levels derived from movement aggregation (no mutable quantity column) - All 5 movement types validated with type-specific rules - API routes: POST/GET items, GET item detail, POST/GET movements, GET stock-levels - CASL: OFFICE_STAFF gets manage Inventory permission - Migration applied: add_inventory_models (4 enums, 2 tables) - 13 tests: registration, all movement types, stock derivation, JE posting, history Co-Authored-By: Claude Opus 4.6 --- .../migration.sql | 77 +++ .../inventory/items/[id]/movements/route.ts | 87 +++ src/app/api/inventory/items/[id]/route.ts | 34 ++ src/app/api/inventory/items/route.ts | 85 +++ src/app/api/inventory/stock-levels/route.ts | 31 + src/lib/__tests__/inventory-service.test.ts | 537 ++++++++++++++++++ src/lib/casl/permissions.ts | 2 + src/lib/services/inventory-service.ts | 429 ++++++++++++++ 8 files changed, 1282 insertions(+) create mode 100644 prisma/migrations/20260305021700_add_inventory_models/migration.sql create mode 100644 src/app/api/inventory/items/[id]/movements/route.ts create mode 100644 src/app/api/inventory/items/[id]/route.ts create mode 100644 src/app/api/inventory/items/route.ts create mode 100644 src/app/api/inventory/stock-levels/route.ts create mode 100644 src/lib/__tests__/inventory-service.test.ts create mode 100644 src/lib/services/inventory-service.ts diff --git a/prisma/migrations/20260305021700_add_inventory_models/migration.sql b/prisma/migrations/20260305021700_add_inventory_models/migration.sql new file mode 100644 index 0000000..46d574a --- /dev/null +++ b/prisma/migrations/20260305021700_add_inventory_models/migration.sql @@ -0,0 +1,77 @@ +-- CreateEnum +CREATE TYPE "ItemTrackingType" AS ENUM ('SERIALIZED', 'BATCH'); + +-- CreateEnum +CREATE TYPE "ItemCondition" AS ENUM ('NEW', 'REFURBISHED', 'USED', 'DAMAGED'); + +-- CreateEnum +CREATE TYPE "MovementType" AS ENUM ('RECEIVED', 'ISSUED', 'RETURNED', 'DISPOSED', 'TRANSFERRED'); + +-- CreateEnum +CREATE TYPE "LocationType" AS ENUM ('WAREHOUSE', 'TECHNICIAN', 'SUBSCRIBER'); + +-- CreateTable +CREATE TABLE "InventoryItem" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "itemType" TEXT NOT NULL, + "model" TEXT, + "serialNumber" TEXT, + "trackingType" "ItemTrackingType" NOT NULL, + "purchaseCost" DECIMAL(10,2), + "purchaseDate" TIMESTAMP(3), + "warrantyExpiry" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InventoryItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StockMovement" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "inventoryItemId" TEXT NOT NULL, + "movementType" "MovementType" NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + "condition" "ItemCondition", + "fromLocationType" "LocationType", + "fromLocationId" TEXT, + "toLocationType" "LocationType", + "toLocationId" TEXT, + "notes" TEXT, + "journalEntryId" TEXT, + "performedById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "InventoryItem_tenantId_idx" ON "InventoryItem"("tenantId"); + +-- CreateIndex +CREATE INDEX "InventoryItem_tenantId_itemType_idx" ON "InventoryItem"("tenantId", "itemType"); + +-- CreateIndex +CREATE INDEX "InventoryItem_tenantId_trackingType_idx" ON "InventoryItem"("tenantId", "trackingType"); + +-- CreateIndex +CREATE UNIQUE INDEX "InventoryItem_tenantId_serialNumber_key" ON "InventoryItem"("tenantId", "serialNumber"); + +-- CreateIndex +CREATE INDEX "StockMovement_tenantId_idx" ON "StockMovement"("tenantId"); + +-- CreateIndex +CREATE INDEX "StockMovement_inventoryItemId_idx" ON "StockMovement"("inventoryItemId"); + +-- CreateIndex +CREATE INDEX "StockMovement_tenantId_movementType_idx" ON "StockMovement"("tenantId", "movementType"); + +-- AddForeignKey +ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_inventoryItemId_fkey" FOREIGN KEY ("inventoryItemId") REFERENCES "InventoryItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/app/api/inventory/items/[id]/movements/route.ts b/src/app/api/inventory/items/[id]/movements/route.ts new file mode 100644 index 0000000..5765422 --- /dev/null +++ b/src/app/api/inventory/items/[id]/movements/route.ts @@ -0,0 +1,87 @@ +/** + * POST /api/inventory/items/[id]/movements — Record a stock movement for an item + * GET /api/inventory/items/[id]/movements — Get movement history for an item + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { InventoryService } from "@/lib/services/inventory-service"; + +export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("create", "Inventory")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + let body: unknown; + try { + body = await _req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { + movementType, quantity, condition, + fromLocationType, fromLocationId, + toLocationType, toLocationId, + notes, unitCost, + } = body as Record; + + if (!movementType || typeof movementType !== "string") { + return NextResponse.json({ error: "movementType is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const movement = await InventoryService.recordMovement(tenantPrisma, user.tenantId, { + inventoryItemId: id, + movementType: movementType as "RECEIVED" | "ISSUED" | "RETURNED" | "DISPOSED" | "TRANSFERRED", + quantity: quantity as number | undefined, + condition: condition as "NEW" | "REFURBISHED" | "USED" | "DAMAGED" | undefined, + fromLocationType: fromLocationType as "WAREHOUSE" | "TECHNICIAN" | "SUBSCRIBER" | undefined, + fromLocationId: fromLocationId as string | undefined, + toLocationType: toLocationType as "WAREHOUSE" | "TECHNICIAN" | "SUBSCRIBER" | undefined, + toLocationId: toLocationId as string | undefined, + notes: notes as string | undefined, + performedById: user.id, + unitCost: unitCost as number | string | undefined, + }); + return NextResponse.json(movement, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to record movement"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} + +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "Inventory")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const movements = await InventoryService.getItemMovements(tenantPrisma, id); + return NextResponse.json({ movements }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get movements"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } + )(req); +} diff --git a/src/app/api/inventory/items/[id]/route.ts b/src/app/api/inventory/items/[id]/route.ts new file mode 100644 index 0000000..fe113bc --- /dev/null +++ b/src/app/api/inventory/items/[id]/route.ts @@ -0,0 +1,34 @@ +/** + * GET /api/inventory/items/[id] — Get inventory item detail with recent movements + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { InventoryService } from "@/lib/services/inventory-service"; + +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "Inventory")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const item = await InventoryService.getItem(tenantPrisma, id); + if (!item) { + return NextResponse.json({ error: "Item not found" }, { status: 404 }); + } + return NextResponse.json(item); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get item"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } + )(req); +} diff --git a/src/app/api/inventory/items/route.ts b/src/app/api/inventory/items/route.ts new file mode 100644 index 0000000..aaa0320 --- /dev/null +++ b/src/app/api/inventory/items/route.ts @@ -0,0 +1,85 @@ +/** + * POST /api/inventory/items — Register a new inventory item + * GET /api/inventory/items — List inventory items with optional filters + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { InventoryService } from "@/lib/services/inventory-service"; + +export const POST = withPermission("create", "Inventory")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { name, itemType, model, serialNumber, trackingType, purchaseCost, purchaseDate, warrantyExpiry } = + body as Record; + + if (!name || typeof name !== "string") { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + if (!itemType || typeof itemType !== "string") { + return NextResponse.json({ error: "itemType is required" }, { status: 400 }); + } + if (!trackingType || typeof trackingType !== "string") { + return NextResponse.json({ error: "trackingType is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const item = await InventoryService.registerItem(tenantPrisma, user.tenantId, { + name, + itemType, + model: model as string | undefined, + serialNumber: serialNumber as string | undefined, + trackingType: trackingType as "SERIALIZED" | "BATCH", + purchaseCost: purchaseCost as number | string | undefined, + purchaseDate: purchaseDate ? new Date(purchaseDate as string) : undefined, + warrantyExpiry: warrantyExpiry ? new Date(warrantyExpiry as string) : undefined, + }); + return NextResponse.json(item, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to register item"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } +); + +export const GET = withPermission("read", "Inventory")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const itemType = searchParams.get("itemType") ?? undefined; + const trackingType = searchParams.get("trackingType") as "SERIALIZED" | "BATCH" | undefined; + const isActiveParam = searchParams.get("isActive"); + const isActive = isActiveParam != null ? isActiveParam === "true" : undefined; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const items = await InventoryService.listItems(tenantPrisma, { itemType, trackingType, isActive }); + return NextResponse.json({ items }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list items"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } +); diff --git a/src/app/api/inventory/stock-levels/route.ts b/src/app/api/inventory/stock-levels/route.ts new file mode 100644 index 0000000..2d0629a --- /dev/null +++ b/src/app/api/inventory/stock-levels/route.ts @@ -0,0 +1,31 @@ +/** + * GET /api/inventory/stock-levels — Get derived stock levels from movement history + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { InventoryService } from "@/lib/services/inventory-service"; + +export const GET = withPermission("read", "Inventory")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const itemType = searchParams.get("itemType") ?? undefined; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const levels = await InventoryService.getStockLevels(tenantPrisma, { itemType }); + return NextResponse.json({ stockLevels: levels }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get stock levels"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } +); diff --git a/src/lib/__tests__/inventory-service.test.ts b/src/lib/__tests__/inventory-service.test.ts new file mode 100644 index 0000000..7258301 --- /dev/null +++ b/src/lib/__tests__/inventory-service.test.ts @@ -0,0 +1,537 @@ +/** + * Inventory Service Integration Tests + * + * Tests the full inventory lifecycle: + * - Register serialized item (with serial number) + * - Register batch item (without serial number) + * - Reject serialized item without serial number + * - Record RECEIVED movement creates StockMovement + JE (DR 1200, CR 2010) + * - Record ISSUED movement (warehouse to technician) + * - Record RETURNED movement (technician to warehouse) + * - Record DISPOSED movement + * - Derive stock levels from movement history (receive 10, issue 3 = 7 in warehouse) + * - Serialized item: derive current location from latest movement + * - Get movement history returns chronological order + * + * CLEANUP ORDER: + * stockMovements -> inventoryItems -> journalEntryLines -> null reversesEntryId -> + * journalEntries -> accountingPeriods -> accounts -> users -> tenant + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { seedChartOfAccounts } from "@/lib/accounting/seed-coa"; +import { InventoryService } from "@/lib/services/inventory-service"; +import { Prisma, Role, TenantStatus } from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TS = Date.now(); + +let tenantId: string; +let userId: string; + +// Account IDs +let inventoryAccountId: string; // 1200 +let apAccountId: string; // 2010 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tp() { + return withTenantContext(tenantId); +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Create tenant + const tenant = await prisma.tenant.create({ + data: { + name: `Inventory Test Tenant ${TS}`, + slug: `inv-test-${TS}`, + ownerEmail: `inv-${TS}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantId = tenant.id; + + // Seed COA + await prisma.$transaction(async (tx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await seedChartOfAccounts(tx as any, tenantId); + }); + + // Look up account IDs + const accounts = await prisma.account.findMany({ + where: { tenantId, code: { in: ["1200", "2010"] } }, + select: { id: true, code: true }, + }); + const accountMap = new Map(accounts.map((a) => [a.code, a.id])); + inventoryAccountId = accountMap.get("1200")!; + apAccountId = accountMap.get("2010")!; + + expect(inventoryAccountId).toBeDefined(); + expect(apAccountId).toBeDefined(); + + // Create admin user + const user = await prisma.user.create({ + data: { + email: `inv-admin-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Inv", + lastName: "Admin", + tenantId, + roles: [Role.ADMIN], + isActive: true, + }, + }); + userId = user.id; +}); + +afterAll(async () => { + // Cleanup in order: stockMovements -> inventoryItems -> + // journalEntryLines -> null reversesEntryId -> journalEntries -> + // accountingPeriods -> accounts -> users -> tenant + await prisma.stockMovement.deleteMany({ where: { tenantId } }); + await prisma.inventoryItem.deleteMany({ where: { tenantId } }); + await prisma.journalEntryLine.deleteMany({ where: { tenantId } }); + await prisma.journalEntry.updateMany({ + where: { tenantId, reversesEntryId: { not: null } }, + data: { reversesEntryId: null }, + }); + await prisma.journalEntry.deleteMany({ where: { tenantId } }); + await prisma.accountingPeriod.deleteMany({ where: { tenantId } }); + await prisma.account.deleteMany({ where: { tenantId } }); + await prisma.user.deleteMany({ where: { tenantId } }); + await prisma.tenant.deleteMany({ where: { id: tenantId } }); +}); + +// --------------------------------------------------------------------------- +// Tests: Registration +// --------------------------------------------------------------------------- + +describe("registerItem", () => { + it("registers a serialized item with serial number", async () => { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: "Huawei HG8145V5 ONU", + itemType: "ONU", + model: "HG8145V5", + serialNumber: `SN-${TS}-001`, + trackingType: "SERIALIZED", + purchaseCost: 150, + purchaseDate: new Date("2026-01-15"), + warrantyExpiry: new Date("2027-01-15"), + }); + + expect(item.id).toBeDefined(); + expect(item.name).toBe("Huawei HG8145V5 ONU"); + expect(item.itemType).toBe("ONU"); + expect(item.serialNumber).toBe(`SN-${TS}-001`); + expect(item.trackingType).toBe("SERIALIZED"); + expect(item.purchaseCost.toString()).toBe("150"); + }); + + it("registers a batch item without serial number", async () => { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: "Cat6 Ethernet Cable", + itemType: "Cable", + trackingType: "BATCH", + purchaseCost: 5, + }); + + expect(item.id).toBeDefined(); + expect(item.itemType).toBe("Cable"); + expect(item.serialNumber).toBeNull(); + expect(item.trackingType).toBe("BATCH"); + }); + + it("rejects serialized item without serial number", async () => { + await expect( + InventoryService.registerItem(tp(), tenantId, { + name: "Router", + itemType: "Router", + trackingType: "SERIALIZED", + }) + ).rejects.toThrow(/serial number/i); + }); + + it("rejects batch item with serial number", async () => { + await expect( + InventoryService.registerItem(tp(), tenantId, { + name: "Cable", + itemType: "Cable", + trackingType: "BATCH", + serialNumber: "SHOULD-NOT-EXIST", + }) + ).rejects.toThrow(/must not have a serial number/i); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Movements +// --------------------------------------------------------------------------- + +describe("recordMovement", () => { + it("RECEIVED movement creates StockMovement + JE (DR 1200, CR 2010)", async () => { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: `Router Recv ${TS}`, + itemType: "Router", + model: "MikroTik hAP ac3", + serialNumber: `SN-RECV-${TS}`, + trackingType: "SERIALIZED", + purchaseCost: 200, + }); + + const movement = await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RECEIVED", + quantity: 1, + condition: "NEW", + toLocationType: "WAREHOUSE", + toLocationId: "main-warehouse", + performedById: userId, + }); + + expect(movement.id).toBeDefined(); + expect(movement.movementType).toBe("RECEIVED"); + expect(movement.quantity).toBe(1); + expect(movement.journalEntryId).not.toBeNull(); + + // Verify JE: DR 1200 Equipment Inventory, CR 2010 Accounts Payable + const je = await prisma.journalEntry.findUnique({ + where: { id: movement.journalEntryId! }, + include: { lines: true }, + }); + + expect(je).not.toBeNull(); + expect(je!.status).toBe("POSTED"); + expect(je!.referenceType).toBe("StockMovement"); + + const debitLine = je!.lines.find((l) => new Prisma.Decimal(l.debit).greaterThan(0)); + const creditLine = je!.lines.find((l) => new Prisma.Decimal(l.credit).greaterThan(0)); + + expect(debitLine?.accountId).toBe(inventoryAccountId); // 1200 + expect(creditLine?.accountId).toBe(apAccountId); // 2010 + expect(debitLine?.debit.toString()).toBe("200"); + expect(creditLine?.credit.toString()).toBe("200"); + }); + + it("ISSUED movement (warehouse to technician)", async () => { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: `ONU Issue ${TS}`, + itemType: "ONU", + serialNumber: `SN-ISSUE-${TS}`, + trackingType: "SERIALIZED", + purchaseCost: 100, + }); + + // First receive it + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RECEIVED", + toLocationType: "WAREHOUSE", + toLocationId: "main-warehouse", + performedById: userId, + }); + + // Then issue to technician + const issued = await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "ISSUED", + fromLocationType: "WAREHOUSE", + fromLocationId: "main-warehouse", + toLocationType: "TECHNICIAN", + toLocationId: userId, + performedById: userId, + }); + + expect(issued.movementType).toBe("ISSUED"); + expect(issued.fromLocationType).toBe("WAREHOUSE"); + expect(issued.toLocationType).toBe("TECHNICIAN"); + // ISSUED does not create a JE (only RECEIVED does) + expect(issued.journalEntryId).toBeNull(); + }); + + it("RETURNED movement (technician to warehouse)", async () => { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: `ONU Return ${TS}`, + itemType: "ONU", + serialNumber: `SN-RETURN-${TS}`, + trackingType: "SERIALIZED", + purchaseCost: 100, + }); + + // Receive and issue first + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RECEIVED", + toLocationType: "WAREHOUSE", + toLocationId: "main-warehouse", + performedById: userId, + }); + + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "ISSUED", + fromLocationType: "WAREHOUSE", + fromLocationId: "main-warehouse", + toLocationType: "TECHNICIAN", + toLocationId: userId, + performedById: userId, + }); + + // Return it + const returned = await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RETURNED", + condition: "USED", + fromLocationType: "TECHNICIAN", + fromLocationId: userId, + toLocationType: "WAREHOUSE", + toLocationId: "main-warehouse", + performedById: userId, + }); + + expect(returned.movementType).toBe("RETURNED"); + expect(returned.condition).toBe("USED"); + expect(returned.toLocationType).toBe("WAREHOUSE"); + }); + + it("DISPOSED movement", async () => { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: `ONU Dispose ${TS}`, + itemType: "ONU", + serialNumber: `SN-DISPOSE-${TS}`, + trackingType: "SERIALIZED", + purchaseCost: 100, + }); + + // Receive first + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RECEIVED", + toLocationType: "WAREHOUSE", + toLocationId: "main-warehouse", + performedById: userId, + }); + + // Dispose + const disposed = await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "DISPOSED", + condition: "DAMAGED", + fromLocationType: "WAREHOUSE", + fromLocationId: "main-warehouse", + notes: "Water damage beyond repair", + performedById: userId, + }); + + expect(disposed.movementType).toBe("DISPOSED"); + expect(disposed.condition).toBe("DAMAGED"); + expect(disposed.toLocationType).toBeNull(); + }); + + it("rejects SERIALIZED item with quantity > 1", async () => { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: `Router Qty ${TS}`, + itemType: "Router", + serialNumber: `SN-QTY-${TS}`, + trackingType: "SERIALIZED", + }); + + await expect( + InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RECEIVED", + quantity: 5, + toLocationType: "WAREHOUSE", + toLocationId: "main-warehouse", + performedById: userId, + }) + ).rejects.toThrow(/quantity of 1/i); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Stock Level Derivation +// --------------------------------------------------------------------------- + +describe("getStockLevels", () => { + it("derives stock levels from movement history (receive 10, issue 3 = 7 in warehouse)", async () => { + // Create a batch item (cable) + const cable = await InventoryService.registerItem(tp(), tenantId, { + name: `Cat6 Cable Batch ${TS}`, + itemType: "Cable", + trackingType: "BATCH", + purchaseCost: 5, + }); + + // Receive 10 units + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: cable.id, + movementType: "RECEIVED", + quantity: 10, + toLocationType: "WAREHOUSE", + toLocationId: "stock-wh", + performedById: userId, + }); + + // Issue 3 to technician + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: cable.id, + movementType: "ISSUED", + quantity: 3, + fromLocationType: "WAREHOUSE", + fromLocationId: "stock-wh", + toLocationType: "TECHNICIAN", + toLocationId: userId, + performedById: userId, + }); + + const levels = await InventoryService.getStockLevels(tp()); + + // Find the cable in warehouse + const cableInWarehouse = levels.find( + (l) => l.itemId === cable.id && l.locationType === "WAREHOUSE" && l.locationId === "stock-wh" + ); + expect(cableInWarehouse).toBeDefined(); + expect(cableInWarehouse!.quantity).toBe(7); // 10 received - 3 issued + + // Find the cable with technician + const cableWithTech = levels.find( + (l) => l.itemId === cable.id && l.locationType === "TECHNICIAN" && l.locationId === userId + ); + expect(cableWithTech).toBeDefined(); + expect(cableWithTech!.quantity).toBe(3); + }); + + it("serialized item: derive current location from latest movement", async () => { + const onu = await InventoryService.registerItem(tp(), tenantId, { + name: `ONU Location ${TS}`, + itemType: "ONU", + serialNumber: `SN-LOC-${TS}`, + trackingType: "SERIALIZED", + purchaseCost: 120, + }); + + // Receive at warehouse + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: onu.id, + movementType: "RECEIVED", + toLocationType: "WAREHOUSE", + toLocationId: "wh-loc", + performedById: userId, + }); + + // Issue to technician + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: onu.id, + movementType: "ISSUED", + fromLocationType: "WAREHOUSE", + fromLocationId: "wh-loc", + toLocationType: "TECHNICIAN", + toLocationId: userId, + performedById: userId, + }); + + const levels = await InventoryService.getStockLevels(tp()); + + // ONU should be with technician (quantity 1), NOT in warehouse + const onuWithTech = levels.find( + (l) => l.itemId === onu.id && l.locationType === "TECHNICIAN" + ); + expect(onuWithTech).toBeDefined(); + expect(onuWithTech!.quantity).toBe(1); + + // Warehouse should have 0 (filtered out from results) + const onuInWarehouse = levels.find( + (l) => l.itemId === onu.id && l.locationType === "WAREHOUSE" && l.locationId === "wh-loc" && l.quantity > 0 + ); + expect(onuInWarehouse).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Movement History +// --------------------------------------------------------------------------- + +describe("getItemMovements", () => { + it("returns movement history in chronological order", async () => { + const item = await InventoryService.registerItem(tp(), tenantId, { + name: `ONU History ${TS}`, + itemType: "ONU", + serialNumber: `SN-HIST-${TS}`, + trackingType: "SERIALIZED", + purchaseCost: 100, + }); + + // Create multiple movements + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RECEIVED", + toLocationType: "WAREHOUSE", + toLocationId: "hist-wh", + performedById: userId, + }); + + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "ISSUED", + fromLocationType: "WAREHOUSE", + fromLocationId: "hist-wh", + toLocationType: "TECHNICIAN", + toLocationId: userId, + performedById: userId, + }); + + await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: "RETURNED", + fromLocationType: "TECHNICIAN", + fromLocationId: userId, + toLocationType: "WAREHOUSE", + toLocationId: "hist-wh", + performedById: userId, + }); + + const movements = await InventoryService.getItemMovements(tp(), item.id); + + expect(movements).toHaveLength(3); + expect(movements[0].movementType).toBe("RECEIVED"); + expect(movements[1].movementType).toBe("ISSUED"); + expect(movements[2].movementType).toBe("RETURNED"); + + // Verify chronological order (each createdAt >= previous) + for (let i = 1; i < movements.length; i++) { + expect(new Date(movements[i].createdAt).getTime()).toBeGreaterThanOrEqual( + new Date(movements[i - 1].createdAt).getTime() + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Tests: List Items +// --------------------------------------------------------------------------- + +describe("listItems", () => { + it("lists items with itemType filter", async () => { + // Create items of different types + await InventoryService.registerItem(tp(), tenantId, { + name: `Filter Router ${TS}`, + itemType: "Router", + serialNumber: `SN-FILTER-${TS}`, + trackingType: "SERIALIZED", + }); + + const items = await InventoryService.listItems(tp(), { itemType: "Router" }); + expect(items.length).toBeGreaterThanOrEqual(1); + expect(items.every((i: { itemType: string }) => i.itemType === "Router")).toBe(true); + }); +}); diff --git a/src/lib/casl/permissions.ts b/src/lib/casl/permissions.ts index c05a520..b903865 100644 --- a/src/lib/casl/permissions.ts +++ b/src/lib/casl/permissions.ts @@ -60,6 +60,8 @@ export function definePermissionsFor( // Technician profile management (read/update, not create — admin only for compensation config) can("read", "TechnicianProfile"); can("update", "TechnicianProfile"); + // Inventory management + can("manage", "Inventory"); // Job type rates (read-only for office staff — admin configures rates) can("read", "JobTypeRate"); // View financial reports (read-only) diff --git a/src/lib/services/inventory-service.ts b/src/lib/services/inventory-service.ts new file mode 100644 index 0000000..99450dd --- /dev/null +++ b/src/lib/services/inventory-service.ts @@ -0,0 +1,429 @@ +/** + * InventoryService — Hardware item registration, immutable stock movements, and derived stock levels. + * + * ARCHITECTURE: + * - Dual tracking: SERIALIZED items (routers, ONUs) tracked individually by serial number; + * BATCH items (cables, connectors) tracked by type+quantity. + * - Immutable movement ledger: stock levels are NEVER stored — always derived from movement history. + * - RECEIVED movements auto-post journal entries (DR 1200 Equipment Inventory, CR 2010 AP). + * + * ACCOUNT CODES USED: + * 1200 — Equipment Inventory (asset) + * 2010 — Accounts Payable (liability) + * + * JOURNAL ENTRY PATTERN (RECEIVED): + * DR Equipment Inventory (1200) [purchaseCost] + * CR Accounts Payable (2010) [purchaseCost] + */ + +import { Prisma, JournalEntrySource, ItemTrackingType, MovementType, LocationType } from "@prisma/client"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export interface RegisterItemInput { + name: string; + itemType: string; + model?: string; + serialNumber?: string; + trackingType: ItemTrackingType; + purchaseCost?: number | string; + purchaseDate?: Date; + warrantyExpiry?: Date; +} + +export interface RecordMovementInput { + inventoryItemId: string; + movementType: MovementType; + quantity?: number; + condition?: "NEW" | "REFURBISHED" | "USED" | "DAMAGED"; + fromLocationType?: LocationType; + fromLocationId?: string; + toLocationType?: LocationType; + toLocationId?: string; + notes?: string; + performedById: string; + /** Cost for RECEIVED movements (defaults to item purchaseCost) */ + unitCost?: number | string; +} + +export interface ListItemsFilter { + itemType?: string; + trackingType?: ItemTrackingType; + isActive?: boolean; +} + +export interface StockLevelEntry { + itemId: string | null; + itemName: string; + itemType: string; + locationType: string; + locationId: string; + quantity: number; +} + +// --------------------------------------------------------------------------- +// InventoryService +// --------------------------------------------------------------------------- + +export class InventoryService { + /** + * Register a new inventory item. + * + * Validates: + * - SERIALIZED items must have a serialNumber + * - BATCH items must not have a serialNumber + */ + static async registerItem( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: RegisterItemInput + ) { + // Validate tracking type vs serial number + if (data.trackingType === ItemTrackingType.SERIALIZED && !data.serialNumber) { + throw new Error("Serialized items must have a serial number."); + } + if (data.trackingType === ItemTrackingType.BATCH && data.serialNumber) { + throw new Error("Batch items must not have a serial number."); + } + + const item = await tenantPrisma.inventoryItem.create({ + data: { + tenantId, + name: data.name, + itemType: data.itemType, + model: data.model ?? null, + serialNumber: data.serialNumber ?? null, + trackingType: data.trackingType, + purchaseCost: data.purchaseCost != null ? new Prisma.Decimal(data.purchaseCost) : null, + purchaseDate: data.purchaseDate ?? null, + warrantyExpiry: data.warrantyExpiry ?? null, + } as Record, + }); + + return item; + } + + /** + * Record an immutable stock movement. + * + * Validates movement-type-specific rules: + * - RECEIVED: toLocationType required (must be WAREHOUSE), fromLocationType must be null + * - ISSUED: fromLocationType + toLocationType required + * - RETURNED: fromLocationType + toLocationType required, toLocationType must be WAREHOUSE + * - DISPOSED: fromLocationType required, toLocationType must be null + * - TRANSFERRED: both from + to required + * - SERIALIZED items: quantity must be 1 + * + * For RECEIVED movements: auto-creates JE (DR 1200, CR 2010) using purchaseCost. + */ + static async recordMovement( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: RecordMovementInput + ) { + // Load the item to check tracking type and purchaseCost + const item = await tenantPrisma.inventoryItem.findFirst({ + where: { id: data.inventoryItemId }, + }); + + if (!item) { + throw new Error(`Inventory item not found: ${data.inventoryItemId}`); + } + + const quantity = data.quantity ?? 1; + + // SERIALIZED items must have quantity = 1 + if (item.trackingType === ItemTrackingType.SERIALIZED && quantity !== 1) { + throw new Error("Serialized items must have quantity of 1."); + } + + // Validate movement-type-specific rules + switch (data.movementType) { + case MovementType.RECEIVED: + if (data.fromLocationType) { + throw new Error("RECEIVED movements must not have a fromLocationType."); + } + if (!data.toLocationType) { + throw new Error("RECEIVED movements must have a toLocationType."); + } + if (data.toLocationType !== LocationType.WAREHOUSE) { + throw new Error("RECEIVED movements must be received into a WAREHOUSE."); + } + break; + + case MovementType.ISSUED: + if (!data.fromLocationType || !data.toLocationType) { + throw new Error("ISSUED movements must have both fromLocationType and toLocationType."); + } + break; + + case MovementType.RETURNED: + if (!data.fromLocationType || !data.toLocationType) { + throw new Error("RETURNED movements must have both fromLocationType and toLocationType."); + } + if (data.toLocationType !== LocationType.WAREHOUSE) { + throw new Error("RETURNED movements must be returned to a WAREHOUSE."); + } + break; + + case MovementType.DISPOSED: + if (!data.fromLocationType) { + throw new Error("DISPOSED movements must have a fromLocationType."); + } + if (data.toLocationType) { + throw new Error("DISPOSED movements must not have a toLocationType."); + } + break; + + case MovementType.TRANSFERRED: + if (!data.fromLocationType || !data.toLocationType) { + throw new Error("TRANSFERRED movements must have both fromLocationType and toLocationType."); + } + break; + } + + // For RECEIVED movements: auto-create JE (DR 1200 Equipment Inventory, CR 2010 AP) + let journalEntryId: string | null = null; + + if (data.movementType === MovementType.RECEIVED) { + const cost = data.unitCost != null + ? new Prisma.Decimal(data.unitCost) + : item.purchaseCost + ? new Prisma.Decimal(item.purchaseCost) + : null; + + if (cost && cost.greaterThan(0)) { + const totalCost = cost.mul(quantity); + + // Find accounts + const [inventoryAccount, apAccount] = await Promise.all([ + tenantPrisma.account.findFirst({ where: { code: "1200" }, select: { id: true } }), + tenantPrisma.account.findFirst({ where: { code: "2010" }, select: { id: true } }), + ]); + + if (!inventoryAccount || !apAccount) { + throw new Error("Required accounts (1200, 2010) not found for this tenant."); + } + + const je = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date(), + description: `Received inventory: ${item.name} x${quantity}`, + source: JournalEntrySource.SYSTEM, + referenceType: "StockMovement", + referenceId: data.inventoryItemId, + createdById: data.performedById, + lines: [ + { + accountId: inventoryAccount.id, + debit: totalCost.toNumber(), + credit: 0, + description: `Equipment Inventory: ${item.name}`, + }, + { + accountId: apAccount.id, + debit: 0, + credit: totalCost.toNumber(), + description: `Accounts Payable: ${item.name}`, + }, + ], + }); + + journalEntryId = je.id; + } + } + + // Create the immutable movement record + const movement = await tenantPrisma.stockMovement.create({ + data: { + tenantId, + inventoryItemId: data.inventoryItemId, + movementType: data.movementType, + quantity, + condition: data.condition ?? null, + fromLocationType: data.fromLocationType ?? null, + fromLocationId: data.fromLocationId ?? null, + toLocationType: data.toLocationType ?? null, + toLocationId: data.toLocationId ?? null, + notes: data.notes ?? null, + journalEntryId, + performedById: data.performedById, + } as Record, + }); + + return movement; + } + + /** + * Derive current stock levels from movement history. + * + * Stock calculation: + * - RECEIVED/RETURNED: add to stock at toLocation + * - ISSUED/TRANSFERRED: remove from fromLocation, add to toLocation + * - DISPOSED: remove from fromLocation + * + * For serialized items: returns current location derived from latest movement. + * For batch items: returns aggregated quantities by location. + */ + static async getStockLevels( + tenantPrisma: TenantPrismaClient, + filters?: { itemType?: string } + ): Promise { + // Fetch all movements, ordered chronologically + const itemWhere: Record = {}; + if (filters?.itemType) { + itemWhere.itemType = filters.itemType; + } + + const movements = await tenantPrisma.stockMovement.findMany({ + include: { + inventoryItem: { + select: { + id: true, + name: true, + itemType: true, + trackingType: true, + }, + }, + }, + orderBy: { createdAt: "asc" }, + where: itemWhere.itemType + ? { inventoryItem: { itemType: itemWhere.itemType } } + : undefined, + }); + + // Build stock map: key = `${itemId}:${locationType}:${locationId}` + const stockMap = new Map(); + + function addStock( + itemId: string, + itemName: string, + itemType: string, + locationType: string, + locationId: string, + qty: number + ) { + const key = `${itemId}:${locationType}:${locationId}`; + const existing = stockMap.get(key); + if (existing) { + existing.quantity += qty; + } else { + stockMap.set(key, { + itemId, + itemName, + itemType, + locationType, + locationId, + quantity: qty, + }); + } + } + + for (const mv of movements) { + const item = mv.inventoryItem; + const qty = mv.quantity; + + switch (mv.movementType) { + case MovementType.RECEIVED: + case "RETURNED": + // Add to destination + if (mv.toLocationType && mv.toLocationId) { + addStock(item.id, item.name, item.itemType, mv.toLocationType, mv.toLocationId, qty); + } + break; + + case MovementType.ISSUED: + case "TRANSFERRED": + // Remove from source, add to destination + if (mv.fromLocationType && mv.fromLocationId) { + addStock(item.id, item.name, item.itemType, mv.fromLocationType, mv.fromLocationId, -qty); + } + if (mv.toLocationType && mv.toLocationId) { + addStock(item.id, item.name, item.itemType, mv.toLocationType, mv.toLocationId, qty); + } + break; + + case MovementType.DISPOSED: + // Remove from source + if (mv.fromLocationType && mv.fromLocationId) { + addStock(item.id, item.name, item.itemType, mv.fromLocationType, mv.fromLocationId, -qty); + } + break; + } + } + + // Filter out zero-quantity entries and return + return Array.from(stockMap.values()).filter((e) => e.quantity !== 0); + } + + /** + * Get chronological movement history for an item. + */ + static async getItemMovements( + tenantPrisma: TenantPrismaClient, + itemId: string + ) { + const movements = await tenantPrisma.stockMovement.findMany({ + where: { inventoryItemId: itemId }, + orderBy: { createdAt: "asc" }, + include: { + performedBy: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return movements; + } + + /** + * List inventory items with optional filters. + */ + static async listItems( + tenantPrisma: TenantPrismaClient, + filters?: ListItemsFilter + ) { + const where: Record = {}; + if (filters?.itemType) where.itemType = filters.itemType; + if (filters?.trackingType) where.trackingType = filters.trackingType; + if (filters?.isActive !== undefined) where.isActive = filters.isActive; + + const items = await tenantPrisma.inventoryItem.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + + return items; + } + + /** + * Get a single inventory item by ID. + */ + static async getItem(tenantPrisma: TenantPrismaClient, itemId: string) { + const item = await tenantPrisma.inventoryItem.findFirst({ + where: { id: itemId }, + include: { + movements: { + orderBy: { createdAt: "desc" }, + take: 10, + }, + }, + }); + + return item; + } +}