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:
74
src/app/api/inventory/items/[id]/assign/route.ts
Normal file
74
src/app/api/inventory/items/[id]/assign/route.ts
Normal 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);
|
||||
}
|
||||
52
src/app/api/inventory/items/[id]/dispose/route.ts
Normal file
52
src/app/api/inventory/items/[id]/dispose/route.ts
Normal 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);
|
||||
}
|
||||
34
src/app/api/inventory/items/[id]/history/route.ts
Normal file
34
src/app/api/inventory/items/[id]/history/route.ts
Normal 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);
|
||||
}
|
||||
53
src/app/api/inventory/items/[id]/return/route.ts
Normal file
53
src/app/api/inventory/items/[id]/return/route.ts
Normal 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);
|
||||
}
|
||||
449
src/lib/services/asset-service.ts
Normal file
449
src/lib/services/asset-service.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user