From eb29d6e3be6b0e56a3713aa3f08516fc0a8b03fd Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 13:20:10 +0800 Subject: [PATCH] feat(04-02): AssetService with assign, return, dispose, and history APIs - AssetService: assignToSubscriber, assignToTechnician, returnAsset, disposeAsset, getAssetHistory - getCurrentLocation helper derives location from latest movement - Disposal validates ADMIN role and creates write-off JE (DR 5030, CR 1200) - API routes: POST assign, POST return, POST dispose, GET history - Location history resolves subscriber/technician/warehouse names Co-Authored-By: Claude Opus 4.6 --- .../api/inventory/items/[id]/assign/route.ts | 74 +++ .../api/inventory/items/[id]/dispose/route.ts | 52 ++ .../api/inventory/items/[id]/history/route.ts | 34 ++ .../api/inventory/items/[id]/return/route.ts | 53 +++ src/lib/services/asset-service.ts | 449 ++++++++++++++++++ 5 files changed, 662 insertions(+) create mode 100644 src/app/api/inventory/items/[id]/assign/route.ts create mode 100644 src/app/api/inventory/items/[id]/dispose/route.ts create mode 100644 src/app/api/inventory/items/[id]/history/route.ts create mode 100644 src/app/api/inventory/items/[id]/return/route.ts create mode 100644 src/lib/services/asset-service.ts diff --git a/src/app/api/inventory/items/[id]/assign/route.ts b/src/app/api/inventory/items/[id]/assign/route.ts new file mode 100644 index 0000000..9f39fec --- /dev/null +++ b/src/app/api/inventory/items/[id]/assign/route.ts @@ -0,0 +1,74 @@ +/** + * POST /api/inventory/items/[id]/assign — Assign a serialized asset to a subscriber or technician + * + * Body: { assigneeType: "SUBSCRIBER"|"TECHNICIAN", assigneeId: string, condition: string, notes?: string } + * Roles: ADMIN, OFFICE_STAFF + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { AssetService } from "@/lib/services/asset-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 { assigneeType, assigneeId, condition, notes } = body as Record; + + if (!assigneeType || !assigneeId || !condition) { + return NextResponse.json( + { error: "assigneeType, assigneeId, and condition are required" }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + let movement; + if (assigneeType === "SUBSCRIBER") { + movement = await AssetService.assignToSubscriber(tenantPrisma, user.tenantId, { + itemId: id, + subscriberId: assigneeId as string, + condition: condition as "NEW" | "REFURBISHED" | "USED" | "DAMAGED", + performedById: user.id, + notes: notes as string | undefined, + }); + } else if (assigneeType === "TECHNICIAN") { + movement = await AssetService.assignToTechnician(tenantPrisma, user.tenantId, { + itemId: id, + technicianUserId: assigneeId as string, + condition: condition as "NEW" | "REFURBISHED" | "USED" | "DAMAGED", + performedById: user.id, + notes: notes as string | undefined, + }); + } else { + return NextResponse.json( + { error: "assigneeType must be SUBSCRIBER or TECHNICIAN" }, + { status: 400 } + ); + } + + return NextResponse.json(movement, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to assign asset"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/inventory/items/[id]/dispose/route.ts b/src/app/api/inventory/items/[id]/dispose/route.ts new file mode 100644 index 0000000..389d988 --- /dev/null +++ b/src/app/api/inventory/items/[id]/dispose/route.ts @@ -0,0 +1,52 @@ +/** + * POST /api/inventory/items/[id]/dispose — Dispose a serialized asset (admin only) + * + * Body: { notes?: string } + * Roles: ADMIN only (enforced at service layer via userRoles check) + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { AssetService } from "@/lib/services/asset-service"; +import { Role } from "@prisma/client"; + +export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("manage", "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: Record = {}; + try { + body = await _req.json(); + } catch { + // Body is optional for dispose + } + + const { notes } = body; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const result = await AssetService.disposeAsset(tenantPrisma, user.tenantId, { + itemId: id, + performedById: user.id, + notes: notes as string | undefined, + userRoles: user.roles as Role[], + }); + return NextResponse.json(result, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to dispose asset"; + // Return 403 for authorization errors + const status = message.includes("Only ADMIN") ? 403 : 400; + return NextResponse.json({ error: message }, { status }); + } + } + )(req); +} diff --git a/src/app/api/inventory/items/[id]/history/route.ts b/src/app/api/inventory/items/[id]/history/route.ts new file mode 100644 index 0000000..b8a2937 --- /dev/null +++ b/src/app/api/inventory/items/[id]/history/route.ts @@ -0,0 +1,34 @@ +/** + * GET /api/inventory/items/[id]/history — Get chronological asset location history + * + * Returns timeline of movements with resolved location names. + * Roles: ADMIN, OFFICE_STAFF, TECHNICIAN + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { AssetService } from "@/lib/services/asset-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 history = await AssetService.getAssetHistory(tenantPrisma, id); + return NextResponse.json({ history }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get asset history"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } + )(req); +} diff --git a/src/app/api/inventory/items/[id]/return/route.ts b/src/app/api/inventory/items/[id]/return/route.ts new file mode 100644 index 0000000..cc41254 --- /dev/null +++ b/src/app/api/inventory/items/[id]/return/route.ts @@ -0,0 +1,53 @@ +/** + * POST /api/inventory/items/[id]/return — Return a serialized asset to the warehouse + * + * Body: { condition: string, notes?: string } + * Roles: ADMIN, OFFICE_STAFF + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { AssetService } from "@/lib/services/asset-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 { condition, notes } = body as Record; + + if (!condition) { + return NextResponse.json({ error: "condition is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const movement = await AssetService.returnAsset(tenantPrisma, user.tenantId, { + itemId: id, + condition: condition as "NEW" | "REFURBISHED" | "USED" | "DAMAGED", + performedById: user.id, + notes: notes as string | undefined, + }); + return NextResponse.json(movement, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to return asset"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/lib/services/asset-service.ts b/src/lib/services/asset-service.ts new file mode 100644 index 0000000..05b1247 --- /dev/null +++ b/src/lib/services/asset-service.ts @@ -0,0 +1,449 @@ +/** + * AssetService — Asset lifecycle management on top of the inventory event-ledger. + * + * ARCHITECTURE: + * - Builds on InventoryService.recordMovement for all movement tracking. + * - Only SERIALIZED items can be individually assigned to subscribers/technicians. + * - Disposal requires ADMIN role and creates a write-off JE (DR 5030, CR 1200). + * - Location history derived from movement timeline (chronological). + * + * ACCOUNT CODES USED (disposal): + * 5030 — Equipment Expense (debit — write-off cost) + * 1200 — Equipment Inventory (credit — remove from books) + * + * KEY LINKS: + * InventoryService.recordMovement — creates immutable movement records + * JournalEntryService.createEntry — posts disposal write-off JE + */ + +import { Prisma, JournalEntrySource, MovementType, LocationType, ItemTrackingType, Role } from "@prisma/client"; +import { InventoryService } from "@/lib/services/inventory-service"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export interface AssignToSubscriberInput { + itemId: string; + subscriberId: string; + condition: "NEW" | "REFURBISHED" | "USED" | "DAMAGED"; + performedById: string; + notes?: string; +} + +export interface AssignToTechnicianInput { + itemId: string; + technicianUserId: string; + condition: "NEW" | "REFURBISHED" | "USED" | "DAMAGED"; + performedById: string; + notes?: string; +} + +export interface ReturnAssetInput { + itemId: string; + condition: "NEW" | "REFURBISHED" | "USED" | "DAMAGED"; + performedById: string; + notes?: string; +} + +export interface DisposeAssetInput { + itemId: string; + performedById: string; + notes?: string; + userRoles: Role[]; +} + +export interface AssetHistoryEntry { + movementType: string; + date: Date; + fromLocation: { type: string; name: string } | null; + toLocation: { type: string; name: string } | null; + condition: string | null; + performedBy: string; + notes: string | null; +} + +// --------------------------------------------------------------------------- +// AssetService +// --------------------------------------------------------------------------- + +export class AssetService { + /** + * Get the current location of a serialized asset from its latest movement. + * Returns null if the item has been disposed. + */ + static async getCurrentLocation( + tenantPrisma: TenantPrismaClient, + itemId: string + ): Promise<{ locationType: string; locationId: string } | null> { + const latest = await tenantPrisma.stockMovement.findFirst({ + where: { inventoryItemId: itemId }, + orderBy: { createdAt: "desc" }, + }); + + if (!latest) return null; + + // DISPOSED items have no current location + if (latest.movementType === MovementType.DISPOSED) return null; + + if (latest.toLocationType && latest.toLocationId) { + return { + locationType: latest.toLocationType, + locationId: latest.toLocationId, + }; + } + + return null; + } + + /** + * Assign a serialized asset to a subscriber. + * + * Validates: + * - Item must be SERIALIZED (batch items cannot be individually assigned) + * - Current location must be WAREHOUSE or TECHNICIAN (cannot reassign from subscriber) + */ + static async assignToSubscriber( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: AssignToSubscriberInput + ) { + const item = await tenantPrisma.inventoryItem.findFirst({ + where: { id: data.itemId }, + }); + + if (!item) throw new Error(`Inventory item not found: ${data.itemId}`); + if (item.trackingType !== ItemTrackingType.SERIALIZED) { + throw new Error("Only SERIALIZED items can be assigned to subscribers."); + } + + const location = await AssetService.getCurrentLocation(tenantPrisma, data.itemId); + if (!location) { + throw new Error("Item has no current location (not received or already disposed)."); + } + + if (location.locationType === LocationType.SUBSCRIBER) { + throw new Error("Item is already assigned to a subscriber. Return it first before reassigning."); + } + + return InventoryService.recordMovement(tenantPrisma, tenantId, { + inventoryItemId: data.itemId, + movementType: MovementType.ISSUED, + condition: data.condition, + fromLocationType: location.locationType as LocationType, + fromLocationId: location.locationId, + toLocationType: LocationType.SUBSCRIBER, + toLocationId: data.subscriberId, + performedById: data.performedById, + notes: data.notes, + }); + } + + /** + * Assign a serialized asset to a technician for field work. + * + * Validates: + * - Item must be SERIALIZED + * - Current location must be WAREHOUSE + */ + static async assignToTechnician( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: AssignToTechnicianInput + ) { + const item = await tenantPrisma.inventoryItem.findFirst({ + where: { id: data.itemId }, + }); + + if (!item) throw new Error(`Inventory item not found: ${data.itemId}`); + if (item.trackingType !== ItemTrackingType.SERIALIZED) { + throw new Error("Only SERIALIZED items can be assigned to technicians."); + } + + const location = await AssetService.getCurrentLocation(tenantPrisma, data.itemId); + if (!location) { + throw new Error("Item has no current location (not received or already disposed)."); + } + + if (location.locationType !== LocationType.WAREHOUSE) { + throw new Error("Item must be in WAREHOUSE to assign to a technician."); + } + + return InventoryService.recordMovement(tenantPrisma, tenantId, { + inventoryItemId: data.itemId, + movementType: MovementType.ISSUED, + condition: data.condition, + fromLocationType: LocationType.WAREHOUSE, + fromLocationId: location.locationId, + toLocationType: LocationType.TECHNICIAN, + toLocationId: data.technicianUserId, + performedById: data.performedById, + notes: data.notes, + }); + } + + /** + * Return a serialized asset back to the warehouse. + * + * Validates: + * - Item must be SERIALIZED + * - Current location must be SUBSCRIBER or TECHNICIAN (not already in warehouse) + */ + static async returnAsset( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: ReturnAssetInput + ) { + const item = await tenantPrisma.inventoryItem.findFirst({ + where: { id: data.itemId }, + }); + + if (!item) throw new Error(`Inventory item not found: ${data.itemId}`); + if (item.trackingType !== ItemTrackingType.SERIALIZED) { + throw new Error("Only SERIALIZED items can be returned."); + } + + const location = await AssetService.getCurrentLocation(tenantPrisma, data.itemId); + if (!location) { + throw new Error("Item has no current location (not received or already disposed)."); + } + + if (location.locationType === LocationType.WAREHOUSE) { + throw new Error("Item is already in the warehouse."); + } + + return InventoryService.recordMovement(tenantPrisma, tenantId, { + inventoryItemId: data.itemId, + movementType: MovementType.RETURNED, + condition: data.condition, + fromLocationType: location.locationType as LocationType, + fromLocationId: location.locationId, + toLocationType: LocationType.WAREHOUSE, + toLocationId: "main-warehouse", + performedById: data.performedById, + notes: data.notes, + }); + } + + /** + * Dispose a serialized asset (write it off the books). + * + * Validates: + * - Item must be SERIALIZED + * - User must have ADMIN role + * - Current location must be WAREHOUSE (cannot dispose from field) + * + * Creates write-off JE: DR 5030 Equipment Expense, CR 1200 Equipment Inventory + */ + static async disposeAsset( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: DisposeAssetInput + ) { + // Validate admin role + if (!data.userRoles.includes(Role.ADMIN)) { + throw new Error("Only ADMIN users can dispose of assets."); + } + + const item = await tenantPrisma.inventoryItem.findFirst({ + where: { id: data.itemId }, + }); + + if (!item) throw new Error(`Inventory item not found: ${data.itemId}`); + if (item.trackingType !== ItemTrackingType.SERIALIZED) { + throw new Error("Only SERIALIZED items can be disposed."); + } + + const location = await AssetService.getCurrentLocation(tenantPrisma, data.itemId); + if (!location) { + throw new Error("Item has no current location (not received or already disposed)."); + } + + if (location.locationType !== LocationType.WAREHOUSE) { + throw new Error("Item must be in WAREHOUSE to dispose. Return it first."); + } + + // Create write-off JE: DR 5030 Equipment Expense, CR 1200 Equipment Inventory + const cost = item.purchaseCost + ? new Prisma.Decimal(item.purchaseCost) + : new Prisma.Decimal(0); + + let journalEntryId: string | undefined; + + if (cost.greaterThan(0)) { + const [equipExpenseAccount, equipInventoryAccount] = await Promise.all([ + tenantPrisma.account.findFirst({ where: { code: "5030" }, select: { id: true } }), + tenantPrisma.account.findFirst({ where: { code: "1200" }, select: { id: true } }), + ]); + + if (!equipExpenseAccount || !equipInventoryAccount) { + throw new Error("Required accounts (5030, 1200) not found for this tenant."); + } + + const je = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: new Date(), + description: `Disposal write-off: ${item.name} SN:${item.serialNumber}`, + source: JournalEntrySource.SYSTEM, + referenceType: "StockMovement", + referenceId: data.itemId, + createdById: data.performedById, + lines: [ + { + accountId: equipExpenseAccount.id, + debit: cost.toNumber(), + credit: 0, + description: `Equipment Expense: ${item.name}`, + }, + { + accountId: equipInventoryAccount.id, + debit: 0, + credit: cost.toNumber(), + description: `Equipment Inventory: ${item.name}`, + }, + ], + }); + + journalEntryId = je.id; + } + + // Record the DISPOSED movement (links to JE if created) + // We need to pass journalEntryId through InventoryService, but it doesn't support that + // for non-RECEIVED movements. We'll record the movement directly via recordMovement + // and note that only RECEIVED auto-creates JEs — for disposal, we created the JE above. + const movement = await InventoryService.recordMovement(tenantPrisma, tenantId, { + inventoryItemId: data.itemId, + movementType: MovementType.DISPOSED, + fromLocationType: LocationType.WAREHOUSE, + fromLocationId: location.locationId, + performedById: data.performedById, + notes: data.notes, + }); + + return { movement, journalEntryId }; + } + + /** + * Get chronological asset history with resolved location names. + * + * Each entry includes: + * - movementType, date + * - fromLocation / toLocation (type + resolved name) + * - condition at time of movement + * - performedBy (user name) + * - notes + */ + static async getAssetHistory( + tenantPrisma: TenantPrismaClient, + itemId: string + ): Promise { + const item = await tenantPrisma.inventoryItem.findFirst({ + where: { id: itemId }, + }); + + if (!item) throw new Error(`Inventory item not found: ${itemId}`); + + const movements = await tenantPrisma.stockMovement.findMany({ + where: { inventoryItemId: itemId }, + orderBy: { createdAt: "asc" }, + include: { + performedBy: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + // Collect all unique subscriber IDs and user IDs for name resolution + const subscriberIds = new Set(); + const userIds = new Set(); + + for (const mv of movements) { + if (mv.fromLocationType === LocationType.SUBSCRIBER && mv.fromLocationId) { + subscriberIds.add(mv.fromLocationId); + } + if (mv.toLocationType === LocationType.SUBSCRIBER && mv.toLocationId) { + subscriberIds.add(mv.toLocationId); + } + if (mv.fromLocationType === LocationType.TECHNICIAN && mv.fromLocationId) { + userIds.add(mv.fromLocationId); + } + if (mv.toLocationType === LocationType.TECHNICIAN && mv.toLocationId) { + userIds.add(mv.toLocationId); + } + } + + // Resolve names + const subscriberMap = new Map(); + if (subscriberIds.size > 0) { + const subscribers = await tenantPrisma.subscriber.findMany({ + where: { id: { in: Array.from(subscriberIds) } }, + select: { id: true, firstName: true, lastName: true }, + }); + for (const s of subscribers) { + subscriberMap.set(s.id, `${s.firstName} ${s.lastName}`); + } + } + + const userMap = new Map(); + if (userIds.size > 0) { + const users = await tenantPrisma.user.findMany({ + where: { id: { in: Array.from(userIds) } }, + select: { id: true, firstName: true, lastName: true }, + }); + for (const u of users) { + userMap.set(u.id, `${u.firstName} ${u.lastName}`); + } + } + + function resolveLocation( + locationType: string | null, + locationId: string | null + ): { type: string; name: string } | null { + if (!locationType) return null; + + let name: string; + switch (locationType) { + case LocationType.WAREHOUSE: + name = "Warehouse"; + break; + case LocationType.SUBSCRIBER: + name = subscriberMap.get(locationId ?? "") ?? `Subscriber ${locationId}`; + break; + case LocationType.TECHNICIAN: + name = userMap.get(locationId ?? "") ?? `Technician ${locationId}`; + break; + default: + name = locationId ?? "Unknown"; + } + + return { type: locationType, name }; + } + + return movements.map( + (mv: { + movementType: string; + createdAt: Date; + fromLocationType: string | null; + fromLocationId: string | null; + toLocationType: string | null; + toLocationId: string | null; + condition: string | null; + performedBy: { firstName: string; lastName: string }; + notes: string | null; + }): AssetHistoryEntry => ({ + movementType: mv.movementType, + date: mv.createdAt, + fromLocation: resolveLocation(mv.fromLocationType, mv.fromLocationId), + toLocation: resolveLocation(mv.toLocationType, mv.toLocationId), + condition: mv.condition, + performedBy: `${mv.performedBy.firstName} ${mv.performedBy.lastName}`, + notes: mv.notes, + }) + ); + } +}