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