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 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 10:31:44 +08:00
parent 3712600c4e
commit a742f701e7
8 changed files with 1282 additions and 0 deletions

View File

@@ -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;

View File

@@ -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<string, unknown>;
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);
}

View File

@@ -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);
}

View File

@@ -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<string, unknown>;
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 });
}
}
);

View File

@@ -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 });
}
}
);

View File

@@ -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);
});
});

View File

@@ -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)

View File

@@ -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<string, unknown>,
});
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<string, unknown>,
});
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<StockLevelEntry[]> {
// Fetch all movements, ordered chronologically
const itemWhere: Record<string, unknown> = {};
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<string, {
itemId: string;
itemName: string;
itemType: string;
locationType: string;
locationId: string;
quantity: number;
}>();
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<string, unknown> = {};
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;
}
}