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:
@@ -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;
|
||||||
87
src/app/api/inventory/items/[id]/movements/route.ts
Normal file
87
src/app/api/inventory/items/[id]/movements/route.ts
Normal 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);
|
||||||
|
}
|
||||||
34
src/app/api/inventory/items/[id]/route.ts
Normal file
34
src/app/api/inventory/items/[id]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
85
src/app/api/inventory/items/route.ts
Normal file
85
src/app/api/inventory/items/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
31
src/app/api/inventory/stock-levels/route.ts
Normal file
31
src/app/api/inventory/stock-levels/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
537
src/lib/__tests__/inventory-service.test.ts
Normal file
537
src/lib/__tests__/inventory-service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,6 +60,8 @@ export function definePermissionsFor(
|
|||||||
// Technician profile management (read/update, not create — admin only for compensation config)
|
// Technician profile management (read/update, not create — admin only for compensation config)
|
||||||
can("read", "TechnicianProfile");
|
can("read", "TechnicianProfile");
|
||||||
can("update", "TechnicianProfile");
|
can("update", "TechnicianProfile");
|
||||||
|
// Inventory management
|
||||||
|
can("manage", "Inventory");
|
||||||
// Job type rates (read-only for office staff — admin configures rates)
|
// Job type rates (read-only for office staff — admin configures rates)
|
||||||
can("read", "JobTypeRate");
|
can("read", "JobTypeRate");
|
||||||
// View financial reports (read-only)
|
// View financial reports (read-only)
|
||||||
|
|||||||
429
src/lib/services/inventory-service.ts
Normal file
429
src/lib/services/inventory-service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user