const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { parseDateOnlyString } = require("../utils/date"); const { issueDocumentNumber } = require("../services/documentSequence"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags'); const { ASSET_ERP_TARGET, enqueueAssetLedgerSummaryExport, resolveAssetLedgerSummaryChange, } = require('../services/integrations/erpAsset'); const { sendData } = require("../utils/reply"); const UUID_SCHEMA = z.string().uuid(); const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional(); const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const ASSET_STATUS_VALUES = ["in_stock", "in_use", "under_repair", "retired", "archived"]; const CATEGORY_LIST_QUERY_SCHEMA = z.object({ includeArchived: BOOLEAN_QUERY_SCHEMA, enabled: BOOLEAN_QUERY_SCHEMA, }); const ASSET_LIST_QUERY_SCHEMA = z.object({ status: z.enum(ASSET_STATUS_VALUES).optional(), categoryId: UUID_SCHEMA.optional(), assigneeId: UUID_SCHEMA.optional(), legalEntityId: UUID_SCHEMA.optional(), costCenterId: UUID_SCHEMA.optional(), q: z.string().trim().max(120).optional(), mine: BOOLEAN_QUERY_SCHEMA, }); const ASSIGNEE_LIST_QUERY_SCHEMA = z.object({ q: z.string().trim().max(100).optional(), }); const ASSET_CATEGORY_CREATE_SCHEMA = z .object({ code: z.string().trim().min(2).max(64), name: z.string().trim().min(1).max(120), documentCategoryId: UUID_SCHEMA.nullable().optional(), enabled: z.boolean().optional(), }) .strict(); const ASSET_CATEGORY_PATCH_SCHEMA = ASSET_CATEGORY_CREATE_SCHEMA.partial(); const ASSET_CREATE_SCHEMA = z .object({ name: z.string().trim().min(1).max(200), categoryId: UUID_SCHEMA, legalEntityId: UUID_SCHEMA, costCenterId: UUID_SCHEMA, purchaseRequestId: UUID_SCHEMA.nullable().optional(), serialNo: z.string().trim().max(120).nullable().optional(), location: z.string().trim().max(200).nullable().optional(), status: z.enum(ASSET_STATUS_VALUES).optional(), assigneeEmployeeId: UUID_SCHEMA.nullable().optional(), activatedAt: DATE_ONLY_SCHEMA.nullable().optional(), retiredAt: DATE_ONLY_SCHEMA.nullable().optional(), }) .strict(); const ASSET_PATCH_SCHEMA = ASSET_CREATE_SCHEMA.omit({ purchaseRequestId: true, assigneeEmployeeId: true }).partial(); const ASSET_CATEGORY_SELECT = { id: true, code: true, name: true, document_category_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, document_category: { select: { id: true, code: true, name: true, entity_type: true, }, }, }; const ASSET_CARD_SELECT = { id: true, asset_no: true, name: true, category_id: true, legal_entity_id: true, cost_center_id: true, purchase_request_id: true, serial_no: true, location: true, status: true, assignee_employee_id: true, activated_at: true, retired_at: true, created_at: true, updated_at: true, category: { select: { id: true, code: true, name: true, document_category_id: true, enabled: true, archived_at: true, document_category: { select: { id: true, code: true, name: true, }, }, }, }, legal_entity: { select: { id: true, code: true, name: true, }, }, cost_center: { select: { id: true, code: true, name: true, legal_entity_id: true, }, }, purchase_request: { select: { id: true, request_no: true, title: true, status: true, }, }, assignee: { select: { id: true, employee_no: true, name: true, department: { select: { id: true, name: true, }, }, }, }, }; function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseBooleanQueryValue(rawValue) { if (rawValue == null) { return null; } return rawValue === "1" || rawValue === "true"; } function parseUuid(fieldName, value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ [fieldName]: "invalid_uuid" }); } return parsed.data; } function parseCategoryListQuery(query) { const parsed = CATEGORY_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { includeArchived: parseBooleanQueryValue(parsed.data.includeArchived) ?? false, enabled: parseBooleanQueryValue(parsed.data.enabled), }; } function parseAssetListQuery(query) { const parsed = ASSET_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { status: parsed.data.status, categoryId: parsed.data.categoryId, assigneeId: parsed.data.assigneeId, legalEntityId: parsed.data.legalEntityId, costCenterId: parsed.data.costCenterId, q: parsed.data.q?.trim() ?? "", mine: parseBooleanQueryValue(parsed.data.mine), }; } function parseAssigneeListQuery(query) { const parsed = ASSIGNEE_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { q: parsed.data.q?.trim() ?? "" }; } function parseCreateBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parsePatchBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { throw buildValidationError({ body: "empty" }); } return parsed.data; } function normalizeAssetCategoryPayload(body) { return { code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined, name: typeof body.name === "string" ? body.name.trim() : undefined, documentCategoryId: Object.prototype.hasOwnProperty.call(body, "documentCategoryId") ? body.documentCategoryId ?? null : undefined, enabled: body.enabled, }; } function normalizeAssetPayload(body) { return { name: typeof body.name === "string" ? body.name.trim() : undefined, categoryId: body.categoryId, legalEntityId: body.legalEntityId, costCenterId: body.costCenterId, purchaseRequestId: Object.prototype.hasOwnProperty.call(body, "purchaseRequestId") ? body.purchaseRequestId ?? null : undefined, serialNo: Object.prototype.hasOwnProperty.call(body, "serialNo") ? body.serialNo?.trim() ? body.serialNo.trim() : null : undefined, location: Object.prototype.hasOwnProperty.call(body, "location") ? body.location?.trim() ? body.location.trim() : null : undefined, status: body.status, assigneeEmployeeId: Object.prototype.hasOwnProperty.call(body, "assigneeEmployeeId") ? body.assigneeEmployeeId ?? null : undefined, activatedAt: Object.prototype.hasOwnProperty.call(body, "activatedAt") ? body.activatedAt ?? null : undefined, retiredAt: Object.prototype.hasOwnProperty.call(body, "retiredAt") ? body.retiredAt ?? null : undefined, }; } function canReadAssets(fastify, employee) { return ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"].some((permission) => fastify.hasPermission(employee, permission), ); } function canViewAllAssets(fastify, employee) { return ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"].some((permission) => fastify.hasPermission(employee, permission), ); } function canManageAssets(fastify, employee) { return fastify.hasPermission(employee, "asset.manage"); } function ensureAssetReadPermission(fastify, request) { if (!canReadAssets(fastify, request.employee)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "asset.view_own" }, }); } } function ensureAssetViewerOwnsRecord(fastify, request, assetRecord) { if (canViewAllAssets(fastify, request.employee)) { return; } if (assetRecord.assignee_employee_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "asset.view_own" }, }); } } function mapAssetCategory(record) { return { id: record.id, code: record.code, name: record.name, documentCategoryId: record.document_category_id ?? null, enabled: Boolean(record.enabled), archivedAt: record.archived_at?.toISOString?.() ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, documentCategory: record.document_category ? { id: record.document_category.id, code: record.document_category.code, name: record.document_category.name, entityType: record.document_category.entity_type, } : null, }; } function mapAsset(record, request, fastify) { return { id: record.id, assetNo: record.asset_no, name: record.name, categoryId: record.category_id, legalEntityId: record.legal_entity_id, costCenterId: record.cost_center_id, purchaseRequestId: record.purchase_request_id ?? null, serialNo: record.serial_no ?? null, location: record.location ?? null, status: record.status, assigneeEmployeeId: record.assignee_employee_id ?? null, activatedAt: record.activated_at?.toISOString?.().slice(0, 10) ?? null, retiredAt: record.retired_at?.toISOString?.().slice(0, 10) ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, category: record.category ? { id: record.category.id, code: record.category.code, name: record.category.name, documentCategoryId: record.category.document_category_id ?? null, documentCategory: record.category.document_category ? { id: record.category.document_category.id, code: record.category.document_category.code, name: record.category.document_category.name, } : null, } : null, legalEntity: record.legal_entity ? { id: record.legal_entity.id, code: record.legal_entity.code, name: record.legal_entity.name, } : null, costCenter: record.cost_center ? { id: record.cost_center.id, code: record.cost_center.code, name: record.cost_center.name, legalEntityId: record.cost_center.legal_entity_id, } : null, purchaseRequest: record.purchase_request ? { id: record.purchase_request.id, requestNo: record.purchase_request.request_no ?? null, title: record.purchase_request.title, status: record.purchase_request.status, } : null, assignee: record.assignee ? { id: record.assignee.id, employeeNo: record.assignee.employee_no, name: record.assignee.name, department: record.assignee.department ? { id: record.assignee.department.id, name: record.assignee.department.name, } : null, } : null, permissions: { canEdit: canManageAssets(fastify, request.employee), }, }; } function mapAssignee(record) { return { id: record.id, employeeNo: record.employee_no, name: record.name, department: record.department ? { id: record.department.id, name: record.department.name, } : null, }; } async function ensureDocumentCategoryAvailable(tx, documentCategoryId) { if (!documentCategoryId) { return null; } const record = await tx.documentCategory.findUnique({ where: { id: documentCategoryId }, select: { id: true, code: true, name: true, entity_type: true, enabled: true, archived_at: true, }, }); if (!record) { throw buildValidationError({ documentCategoryId: "not_found" }, 404); } if (record.entity_type !== "asset_card") { throw buildValidationError({ documentCategoryId: "unsupported_entity_type" }, 409); } if (!record.enabled || record.archived_at) { throw buildValidationError({ documentCategoryId: "inactive" }, 409); } return record; } async function ensureAssetCategoryExists(tx, categoryId, { requireActive = true } = {}) { const record = await tx.assetCategory.findUnique({ where: { id: categoryId }, select: { id: true, code: true, name: true, document_category_id: true, enabled: true, archived_at: true, document_category: { select: { id: true, code: true, name: true, entity_type: true, enabled: true, archived_at: true, }, }, }, }); if (!record) { throw buildValidationError({ categoryId: "not_found" }, 404); } if (requireActive && (!record.enabled || record.archived_at)) { throw buildValidationError({ categoryId: "inactive" }, 409); } if (record.document_category && (record.document_category.entity_type !== "asset_card" || !record.document_category.enabled || record.document_category.archived_at)) { throw buildValidationError({ categoryId: "invalid_document_category" }, 409); } return record; } async function ensureLegalEntityAvailable(tx, legalEntityId) { const record = await tx.legalEntity.findUnique({ where: { id: legalEntityId }, select: { id: true, code: true, name: true, enabled: true, archived_at: true }, }); if (!record) { throw buildValidationError({ legalEntityId: "not_found" }, 404); } if (!record.enabled || record.archived_at) { throw buildValidationError({ legalEntityId: "inactive" }, 409); } return record; } async function ensureCostCenterAvailable(tx, costCenterId, legalEntityId) { const record = await tx.costCenter.findUnique({ where: { id: costCenterId }, select: { id: true, code: true, name: true, legal_entity_id: true, enabled: true, archived_at: true, }, }); if (!record) { throw buildValidationError({ costCenterId: "not_found" }, 404); } if (!record.enabled || record.archived_at) { throw buildValidationError({ costCenterId: "inactive" }, 409); } if (record.legal_entity_id !== legalEntityId) { throw buildValidationError({ costCenterId: "legal_entity_mismatch" }, 409); } return record; } async function ensureAssigneeEmployee(tx, assigneeEmployeeId) { if (!assigneeEmployeeId) { return null; } const record = await tx.employee.findUnique({ where: { id: assigneeEmployeeId }, select: { id: true, employee_no: true, name: true, status: true, }, }); if (!record) { throw buildValidationError({ assigneeEmployeeId: "not_found" }, 404); } if (record.status !== "active") { throw buildValidationError({ assigneeEmployeeId: "inactive" }, 409); } return record; } async function ensureApprovedPurchaseRequest(tx, purchaseRequestId) { if (!purchaseRequestId) { return null; } const record = await tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, request_no: true, title: true, status: true, }, }); if (!record) { throw buildValidationError({ purchaseRequestId: "not_found" }, 404); } if (record.status !== "approved") { throw buildValidationError({ purchaseRequestId: "not_approved" }, 409); } return record; } async function handleAssetLedgerSummaryErpSync({ tx, config, request, actorId, asset, sourceEventName, changeType, changedFields, evaluateGate = evaluateFeatureFlag, enqueueExport = enqueueAssetLedgerSummaryExport, }) { const gate = await evaluateGate({ prisma: tx, config, flagKey: FEATURE_FLAG_KEYS.erpSync, departmentId: null, strictDepartment: false, }); if (!gate.enabled) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId, action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', targetType: 'asset_card', targetId: asset.id, extra: { erpTarget: ASSET_ERP_TARGET, eventType: 'erp.asset.export', assetId: asset.id, assetNo: asset.asset_no, changeType, gateReason: gate.reason, featureFlag: FEATURE_FLAG_KEYS.erpSync, }, }), }); return { skipped: true, gate, asset, }; } const enqueued = await enqueueExport(tx, config, { asset, sourceEventName, changeType, changedFields, occurredAt: new Date(), actorId, requeueOnFailure: false, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId, action: 'ERP_SYNC_ENQUEUED', targetType: 'asset_card', targetId: asset.id, extra: { erpTarget: ASSET_ERP_TARGET, eventType: enqueued.eventType, assetId: asset.id, assetNo: asset.asset_no, outboxId: enqueued.id, duplicated: enqueued.duplicated, requeued: enqueued.requeued, changeType, changedFields: Array.isArray(changedFields) ? changedFields : [], gateReason: gate.reason, featureFlag: FEATURE_FLAG_KEYS.erpSync, }, }), }); return { skipped: false, gate, asset, enqueued, }; } async function assetsRoutes(fastify) { fastify.get( "/asset-categories", { schema: { tags: ["Assets"], summary: "List Asset Categories" }, preHandler: fastify.authenticate, }, async (request, reply) => { ensureAssetReadPermission(fastify, request); const { includeArchived, enabled } = parseCategoryListQuery(request.query); const where = {}; if (!includeArchived) { where.archived_at = null; } if (enabled !== null) { where.enabled = enabled; } const items = await fastify.prisma.assetCategory.findMany({ where, orderBy: [{ enabled: "desc" }, { code: "asc" }], select: ASSET_CATEGORY_SELECT, }); return sendData(reply, request, { includeArchived, items: items.map(mapAssetCategory), }); }, ); fastify.post( "/asset-categories", { schema: { tags: ["Assets"], summary: "Create Asset Category" }, preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { const payload = normalizeAssetCategoryPayload(parseCreateBody(ASSET_CATEGORY_CREATE_SCHEMA, request.body)); const assetCategory = await fastify.prisma.$transaction(async (tx) => { if (payload.documentCategoryId) { await ensureDocumentCategoryAvailable(tx, payload.documentCategoryId); } const created = await tx.assetCategory.create({ data: { code: payload.code, name: payload.name, document_category_id: payload.documentCategoryId ?? null, enabled: payload.enabled ?? true, created_by: request.employee.id, updated_by: request.employee.id, }, select: ASSET_CATEGORY_SELECT, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_CATEGORY_CREATED", targetType: "asset_category", targetId: created.id, extra: { code: created.code, documentCategoryId: created.document_category_id ?? null, }, }), }); return created; }); return sendData(reply, request, { assetCategory: mapAssetCategory(assetCategory) }); }, ); fastify.patch( "/asset-categories/:id", { schema: { tags: ["Assets"], summary: "Update Asset Category" }, preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { const assetCategoryId = parseUuid("id", request.params?.id); const payload = normalizeAssetCategoryPayload(parsePatchBody(ASSET_CATEGORY_PATCH_SCHEMA, request.body)); const assetCategory = await fastify.prisma.$transaction(async (tx) => { const existing = await tx.assetCategory.findUnique({ where: { id: assetCategoryId }, select: { id: true }, }); if (!existing) { throw buildValidationError({ id: "not_found" }, 404); } if (payload.documentCategoryId) { await ensureDocumentCategoryAvailable(tx, payload.documentCategoryId); } const updated = await tx.assetCategory.update({ where: { id: assetCategoryId }, data: { ...(payload.code !== undefined ? { code: payload.code } : {}), ...(payload.name !== undefined ? { name: payload.name } : {}), ...(payload.documentCategoryId !== undefined ? { document_category_id: payload.documentCategoryId } : {}), ...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}), updated_by: request.employee.id, }, select: ASSET_CATEGORY_SELECT, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_CATEGORY_UPDATED", targetType: "asset_category", targetId: updated.id, extra: { code: updated.code, enabled: updated.enabled, }, }), }); return updated; }); return sendData(reply, request, { assetCategory: mapAssetCategory(assetCategory) }); }, ); fastify.get( "/assets/assignees", { schema: { tags: ["Assets"], summary: "List Asset Assignee Options" }, preHandler: fastify.authenticate, }, async (request, reply) => { if (!canManageAssets(fastify, request.employee)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "asset.manage" }, }); } const { q } = parseAssigneeListQuery(request.query); const items = await fastify.prisma.employee.findMany({ where: { status: "active", archived_at: null, ...(q ? { OR: [ { name: { contains: q, mode: "insensitive" } }, { employee_no: { contains: q, mode: "insensitive" } }, ], } : {}), }, orderBy: [{ name: "asc" }, { employee_no: "asc" }], take: 200, select: { id: true, employee_no: true, name: true, department: { select: { id: true, name: true, }, }, }, }); return sendData(reply, request, { items: items.map(mapAssignee) }); }, ); fastify.get( "/assets", { schema: { tags: ["Assets"], summary: "List Asset Cards" }, preHandler: fastify.authenticate, }, async (request, reply) => { ensureAssetReadPermission(fastify, request); const query = parseAssetListQuery(request.query); const mine = !canViewAllAssets(fastify, request.employee) || query.mine === true; const baseWhere = {}; if (query.status) { baseWhere.status = query.status; } if (query.categoryId) { baseWhere.category_id = query.categoryId; } if (query.legalEntityId) { baseWhere.legal_entity_id = query.legalEntityId; } if (query.costCenterId) { baseWhere.cost_center_id = query.costCenterId; } if (mine) { baseWhere.assignee_employee_id = request.employee.id; } else if (query.assigneeId) { baseWhere.assignee_employee_id = query.assigneeId; } const listOptions = { orderBy: [{ updated_at: "desc" }, { created_at: "desc" }], select: ASSET_CARD_SELECT, }; let items; if (query.q) { items = await fastify.prisma.assetCard.findMany({ where: { ...baseWhere, asset_no: query.q, }, ...listOptions, }); if (!items.length) { items = await fastify.prisma.assetCard.findMany({ where: { ...baseWhere, serial_no: query.q, }, ...listOptions, }); } if (!items.length) { items = await fastify.prisma.assetCard.findMany({ where: { ...baseWhere, name: { contains: query.q, mode: "insensitive", }, }, ...listOptions, }); } } else { items = await fastify.prisma.assetCard.findMany({ where: baseWhere, ...listOptions, }); } return sendData(reply, request, { mine, items: items.map((item) => mapAsset(item, request, fastify)), }); }, ); fastify.post( "/assets", { schema: { tags: ["Assets"], summary: "Create Asset Card" }, preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { const payload = normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, request.body)); const asset = await fastify.prisma.$transaction(async (tx) => { const category = await ensureAssetCategoryExists(tx, payload.categoryId, { requireActive: true }); await ensureLegalEntityAvailable(tx, payload.legalEntityId); await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId); await ensureAssigneeEmployee(tx, payload.assigneeEmployeeId ?? null); const purchaseRequest = await ensureApprovedPurchaseRequest(tx, payload.purchaseRequestId ?? null); const issued = await issueDocumentNumber(tx, { documentType: "asset_card", documentCategoryCode: category.document_category?.code ?? null, actorId: request.employee.id, }); const resolvedStatus = payload.assigneeEmployeeId ? "in_use" : payload.status ?? "in_stock"; const created = await tx.assetCard.create({ data: { asset_no: issued.documentNumber, name: payload.name, category_id: payload.categoryId, legal_entity_id: payload.legalEntityId, cost_center_id: payload.costCenterId, purchase_request_id: payload.purchaseRequestId ?? null, serial_no: payload.serialNo ?? null, location: payload.location ?? null, status: resolvedStatus, assignee_employee_id: payload.assigneeEmployeeId ?? null, activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null, retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null, created_by: request.employee.id, updated_by: request.employee.id, }, select: ASSET_CARD_SELECT, }); if (created.assignee_employee_id) { await tx.assetAssignment.create({ data: { asset_id: created.id, employee_id: created.assignee_employee_id, assigned_at: created.activated_at ?? created.created_at, note: null, }, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_CARD_CREATED", targetType: "asset_card", targetId: created.id, extra: { assetNo: created.asset_no, purchaseRequestId: purchaseRequest?.id ?? null, assigneeEmployeeId: created.assignee_employee_id ?? null, }, }), }); await handleAssetLedgerSummaryErpSync({ tx, config: fastify.config, request, actorId: request.employee.id, asset: created, sourceEventName: 'asset.card.created', changeType: 'created', changedFields: [], }); return created; }); return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); }, ); fastify.get( "/assets/:id", { schema: { tags: ["Assets"], summary: "Get Asset Card Detail" }, preHandler: fastify.authenticate, }, async (request, reply) => { ensureAssetReadPermission(fastify, request); const assetId = parseUuid("id", request.params?.id); const asset = await fastify.prisma.assetCard.findUnique({ where: { id: assetId }, select: ASSET_CARD_SELECT, }); if (!asset) { throw buildValidationError({ id: "not_found" }, 404); } ensureAssetViewerOwnsRecord(fastify, request, asset); return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); }, ); fastify.patch( "/assets/:id", { schema: { tags: ["Assets"], summary: "Update Asset Card" }, preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { const assetId = parseUuid("id", request.params?.id); const payload = normalizeAssetPayload(parsePatchBody(ASSET_PATCH_SCHEMA, request.body)); const asset = await fastify.prisma.$transaction(async (tx) => { const current = await tx.assetCard.findUnique({ where: { id: assetId }, select: { id: true, asset_no: true, name: true, category_id: true, legal_entity_id: true, cost_center_id: true, purchase_request_id: true, serial_no: true, location: true, status: true, assignee_employee_id: true, activated_at: true, retired_at: true, }, }); if (!current) { throw buildValidationError({ id: "not_found" }, 404); } const nextCategoryId = payload.categoryId ?? current.category_id; const nextLegalEntityId = payload.legalEntityId ?? current.legal_entity_id; const nextCostCenterId = payload.costCenterId ?? current.cost_center_id; if (payload.categoryId !== undefined) { await ensureAssetCategoryExists(tx, nextCategoryId, { requireActive: true }); } if (payload.legalEntityId !== undefined) { await ensureLegalEntityAvailable(tx, nextLegalEntityId); } if (payload.costCenterId !== undefined || payload.legalEntityId !== undefined) { await ensureCostCenterAvailable(tx, nextCostCenterId, nextLegalEntityId); } const updated = await tx.assetCard.update({ where: { id: assetId }, data: { ...(payload.name !== undefined ? { name: payload.name } : {}), ...(payload.categoryId !== undefined ? { category_id: payload.categoryId } : {}), ...(payload.legalEntityId !== undefined ? { legal_entity_id: payload.legalEntityId } : {}), ...(payload.costCenterId !== undefined ? { cost_center_id: payload.costCenterId } : {}), ...(payload.serialNo !== undefined ? { serial_no: payload.serialNo } : {}), ...(payload.location !== undefined ? { location: payload.location } : {}), ...(payload.status !== undefined ? { status: payload.status } : {}), ...(payload.activatedAt !== undefined ? { activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null } : {}), ...(payload.retiredAt !== undefined ? { retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null } : {}), updated_by: request.employee.id, }, select: ASSET_CARD_SELECT, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_CARD_UPDATED", targetType: "asset_card", targetId: updated.id, extra: { assetNo: current.asset_no, status: updated.status, assigneeEmployeeId: updated.assignee_employee_id ?? null, }, }), }); const assetLedgerSummaryChange = resolveAssetLedgerSummaryChange(current, updated); if (assetLedgerSummaryChange) { await handleAssetLedgerSummaryErpSync({ tx, config: fastify.config, request, actorId: request.employee.id, asset: updated, sourceEventName: assetLedgerSummaryChange.sourceEventName, changeType: assetLedgerSummaryChange.changeType, changedFields: assetLedgerSummaryChange.changedFields, }); } return updated; }); return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); }, ); } module.exports = assetsRoutes; module.exports.__testables = { handleAssetLedgerSummaryErpSync, };