feat(04-01): InventoryService, API routes, migration, and 13 passing tests

- InventoryService: registerItem, recordMovement, getStockLevels, getItemMovements, listItems
- RECEIVED movements auto-post JE (DR 1200 Equipment Inventory, CR 2010 AP)
- Stock levels derived from movement aggregation (no mutable quantity column)
- All 5 movement types validated with type-specific rules
- API routes: POST/GET items, GET item detail, POST/GET movements, GET stock-levels
- CASL: OFFICE_STAFF gets manage Inventory permission
- Migration applied: add_inventory_models (4 enums, 2 tables)
- 13 tests: registration, all movement types, stock derivation, JE posting, history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 10:31:44 +08:00
parent 3712600c4e
commit a742f701e7
8 changed files with 1282 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
/**
* POST /api/inventory/items/[id]/movements — Record a stock movement for an item
* GET /api/inventory/items/[id]/movements — Get movement history for an item
*/
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { InventoryService } from "@/lib/services/inventory-service";
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("create", "Inventory")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
let body: unknown;
try {
body = await _req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const {
movementType, quantity, condition,
fromLocationType, fromLocationId,
toLocationType, toLocationId,
notes, unitCost,
} = body as Record<string, unknown>;
if (!movementType || typeof movementType !== "string") {
return NextResponse.json({ error: "movementType is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const movement = await InventoryService.recordMovement(tenantPrisma, user.tenantId, {
inventoryItemId: id,
movementType: movementType as "RECEIVED" | "ISSUED" | "RETURNED" | "DISPOSED" | "TRANSFERRED",
quantity: quantity as number | undefined,
condition: condition as "NEW" | "REFURBISHED" | "USED" | "DAMAGED" | undefined,
fromLocationType: fromLocationType as "WAREHOUSE" | "TECHNICIAN" | "SUBSCRIBER" | undefined,
fromLocationId: fromLocationId as string | undefined,
toLocationType: toLocationType as "WAREHOUSE" | "TECHNICIAN" | "SUBSCRIBER" | undefined,
toLocationId: toLocationId as string | undefined,
notes: notes as string | undefined,
performedById: user.id,
unitCost: unitCost as number | string | undefined,
});
return NextResponse.json(movement, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to record movement";
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Inventory")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const movements = await InventoryService.getItemMovements(tenantPrisma, id);
return NextResponse.json({ movements });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get movements";
return NextResponse.json({ error: message }, { status: 500 });
}
}
)(req);
}

View File

@@ -0,0 +1,34 @@
/**
* GET /api/inventory/items/[id] — Get inventory item detail with recent movements
*/
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { InventoryService } from "@/lib/services/inventory-service";
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Inventory")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const item = await InventoryService.getItem(tenantPrisma, id);
if (!item) {
return NextResponse.json({ error: "Item not found" }, { status: 404 });
}
return NextResponse.json(item);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get item";
return NextResponse.json({ error: message }, { status: 500 });
}
}
)(req);
}

View File

@@ -0,0 +1,85 @@
/**
* POST /api/inventory/items — Register a new inventory item
* GET /api/inventory/items — List inventory items with optional filters
*/
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { InventoryService } from "@/lib/services/inventory-service";
export const POST = withPermission("create", "Inventory")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { name, itemType, model, serialNumber, trackingType, purchaseCost, purchaseDate, warrantyExpiry } =
body as Record<string, unknown>;
if (!name || typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (!itemType || typeof itemType !== "string") {
return NextResponse.json({ error: "itemType is required" }, { status: 400 });
}
if (!trackingType || typeof trackingType !== "string") {
return NextResponse.json({ error: "trackingType is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const item = await InventoryService.registerItem(tenantPrisma, user.tenantId, {
name,
itemType,
model: model as string | undefined,
serialNumber: serialNumber as string | undefined,
trackingType: trackingType as "SERIALIZED" | "BATCH",
purchaseCost: purchaseCost as number | string | undefined,
purchaseDate: purchaseDate ? new Date(purchaseDate as string) : undefined,
warrantyExpiry: warrantyExpiry ? new Date(warrantyExpiry as string) : undefined,
});
return NextResponse.json(item, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to register item";
return NextResponse.json({ error: message }, { status: 400 });
}
}
);
export const GET = withPermission("read", "Inventory")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { searchParams } = new URL(req.url);
const itemType = searchParams.get("itemType") ?? undefined;
const trackingType = searchParams.get("trackingType") as "SERIALIZED" | "BATCH" | undefined;
const isActiveParam = searchParams.get("isActive");
const isActive = isActiveParam != null ? isActiveParam === "true" : undefined;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const items = await InventoryService.listItems(tenantPrisma, { itemType, trackingType, isActive });
return NextResponse.json({ items });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to list items";
return NextResponse.json({ error: message }, { status: 500 });
}
}
);

View File

@@ -0,0 +1,31 @@
/**
* GET /api/inventory/stock-levels — Get derived stock levels from movement history
*/
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { InventoryService } from "@/lib/services/inventory-service";
export const GET = withPermission("read", "Inventory")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { searchParams } = new URL(req.url);
const itemType = searchParams.get("itemType") ?? undefined;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const levels = await InventoryService.getStockLevels(tenantPrisma, { itemType });
return NextResponse.json({ stockLevels: levels });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get stock levels";
return NextResponse.json({ error: message }, { status: 500 });
}
}
);