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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user