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,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;
}
}