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 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 13:20:10 +08:00
parent 6dd1fe827c
commit eb29d6e3be
5 changed files with 662 additions and 0 deletions

View File

@@ -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<string, unknown>;
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);
}

View File

@@ -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<string, unknown> = {};
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);
}

View File

@@ -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);
}

View File

@@ -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<string, unknown>;
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);
}

View File

@@ -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<AssetHistoryEntry[]> {
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<string>();
const userIds = new Set<string>();
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<string, string>();
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<string, string>();
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,
})
);
}
}