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