const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { createNotificationsWithRecords } = require("../services/notifications"); const { sendNotificationEmailSafe } = require("../services/email"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const UUID_SCHEMA = z.string().uuid(); const NOTE_SCHEMA = z.string().trim().max(500).nullable().optional(); const LOCATION_SCHEMA = z.string().trim().max(200).nullable().optional(); const ASSIGN_BODY_SCHEMA = z .object({ employeeId: UUID_SCHEMA, note: NOTE_SCHEMA, }) .strict(); const RETURN_BODY_SCHEMA = z .object({ note: NOTE_SCHEMA, }) .strict(); const TRANSFER_BODY_SCHEMA = z .object({ toEmployeeId: UUID_SCHEMA.nullable().optional(), toLocation: LOCATION_SCHEMA, note: NOTE_SCHEMA, }) .strict(); const BLOCKED_MOVEMENT_STATUSES = new Set(["retired", "archived"]); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseUuid(fieldName, value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ [fieldName]: "invalid_uuid" }); } return parsed.data; } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function normalizeNote(value) { if (value == null) { return null; } const trimmed = String(value).trim(); return trimmed ? trimmed : null; } function normalizeLocation(value) { if (value == null) { return null; } const trimmed = String(value).trim(); return trimmed ? trimmed : null; } function uniq(values) { return Array.from(new Set((values ?? []).filter(Boolean))); } function ensureMovementAllowed(asset) { if (BLOCKED_MOVEMENT_STATUSES.has(asset.status)) { throw buildValidationError({ status: "movement_not_allowed" }, 409); } } async function loadAsset(tx, assetId) { return tx.assetCard.findUnique({ where: { id: assetId }, select: { id: true, asset_no: true, status: true, assignee_employee_id: true, location: true, activated_at: true, created_at: true, }, }); } async function ensureAssetExists(tx, assetId) { const asset = await loadAsset(tx, assetId); if (!asset) { throw buildValidationError({ id: "not_found" }, 404); } ensureMovementAllowed(asset); return asset; } async function ensureActiveEmployee(tx, employeeId, fieldName = "employeeId") { const employee = await tx.employee.findUnique({ where: { id: employeeId }, select: { id: true, employee_no: true, name: true, status: true, }, }); if (!employee) { throw buildValidationError({ [fieldName]: "not_found" }, 404); } if (employee.status !== "active") { throw buildValidationError({ [fieldName]: "inactive" }, 409); } return employee; } async function findOpenAssignment(tx, assetId) { return tx.assetAssignment.findFirst({ where: { asset_id: assetId, returned_at: null, }, orderBy: [{ assigned_at: "desc" }, { id: "desc" }], select: { id: true, asset_id: true, employee_id: true, assigned_at: true, returned_at: true, }, }); } async function ensureOpenAssignment(tx, asset) { const existing = await findOpenAssignment(tx, asset.id); if (existing) { return existing; } if (!asset.assignee_employee_id) { return null; } return tx.assetAssignment.create({ data: { asset_id: asset.id, employee_id: asset.assignee_employee_id, assigned_at: asset.activated_at ?? asset.created_at, note: null, }, select: { id: true, asset_id: true, employee_id: true, assigned_at: true, returned_at: true, }, }); } async function closeAssignmentIfAny(tx, asset, note, returnedAt) { const openAssignment = await ensureOpenAssignment(tx, asset); if (!openAssignment) { return null; } return tx.assetAssignment.update({ where: { id: openAssignment.id }, data: { returned_at: returnedAt, note: note ?? undefined, }, select: { id: true, asset_id: true, employee_id: true, assigned_at: true, returned_at: true, }, }); } async function assetMovementsRoutes(fastify) { fastify.post( "/assets/:id/assign", { schema: { tags: ["Assets"], summary: "Assign Asset" }, preHandler: fastify.requirePermission("asset.transfer"), }, async (request, reply) => { const assetId = parseUuid("id", request.params?.id); const body = parseBody(ASSIGN_BODY_SCHEMA, request.body); const note = normalizeNote(body.note); const emailTasks = []; const dispatchTasks = []; const result = await fastify.prisma.$transaction(async (tx) => { const asset = await ensureAssetExists(tx, assetId); const openAssignment = await findOpenAssignment(tx, asset.id); if (asset.assignee_employee_id || openAssignment) { throw buildValidationError({ asset: "already_assigned" }, 409); } const employee = await ensureActiveEmployee(tx, body.employeeId); const assignedAt = new Date(); const assignment = await tx.assetAssignment.create({ data: { asset_id: asset.id, employee_id: employee.id, assigned_at: assignedAt, note, }, select: { id: true, employee_id: true, assigned_at: true, }, }); const updatedAsset = await tx.assetCard.update({ where: { id: asset.id }, data: { assignee_employee_id: employee.id, status: "in_use", updated_by: request.employee.id, }, select: { id: true, asset_no: true, status: true, assignee_employee_id: true, location: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_ASSIGNED", targetType: "asset_card", targetId: asset.id, extra: { assetNo: asset.asset_no, employeeId: employee.id, assignmentId: assignment.id, }, }), }); const recipients = uniq([employee.id]); const title = `【资产领用】资产卡片 ${asset.asset_no}`; const bodyText = `资产卡片 ${asset.asset_no} 已分配给你,请在资产台账中查看。`; const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.assetAssigned, title, body: bodyText, targetType: "asset_card", targetId: asset.id, }); dispatchTasks.push({ notifications, type: NOTIFICATION_TYPES.assetAssigned, title, body: bodyText, targetType: "asset_card", targetId: asset.id, targetPath: "/assets", actorId: request.employee.id, }); emailTasks.push({ type: NOTIFICATION_TYPES.assetAssigned, recipientEmployeeIds: recipients, subject: title, body: bodyText, targetPath: "/assets", }); return { updatedAsset, assignmentId: assignment.id }; }); for (const task of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...task, }); } await flushNotificationDispatchTasks({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, tasks: dispatchTasks, }); return sendData(reply, request, { assetId: result.updatedAsset.id, assetNo: result.updatedAsset.asset_no, status: result.updatedAsset.status, assigneeEmployeeId: result.updatedAsset.assignee_employee_id, location: result.updatedAsset.location ?? null, assignmentId: result.assignmentId, }); }, ); fastify.post( "/assets/:id/return", { schema: { tags: ["Assets"], summary: "Return Asset" }, preHandler: fastify.requirePermission("asset.transfer"), }, async (request, reply) => { const assetId = parseUuid("id", request.params?.id); const body = parseBody(RETURN_BODY_SCHEMA, request.body); const note = normalizeNote(body.note); const emailTasks = []; const dispatchTasks = []; const result = await fastify.prisma.$transaction(async (tx) => { const asset = await ensureAssetExists(tx, assetId); if (!asset.assignee_employee_id) { throw buildValidationError({ asset: "not_assigned" }, 409); } const returnedAt = new Date(); const closedAssignment = await closeAssignmentIfAny(tx, asset, note, returnedAt); const updatedAsset = await tx.assetCard.update({ where: { id: asset.id }, data: { assignee_employee_id: null, status: "in_stock", updated_by: request.employee.id, }, select: { id: true, asset_no: true, status: true, assignee_employee_id: true, location: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_RETURNED", targetType: "asset_card", targetId: asset.id, extra: { assetNo: asset.asset_no, previousEmployeeId: asset.assignee_employee_id, assignmentId: closedAssignment?.id ?? null, }, }), }); const recipients = uniq([asset.assignee_employee_id]); const title = `【资产归还】资产卡片 ${asset.asset_no}`; const bodyText = `资产卡片 ${asset.asset_no} 已完成归还。`; const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.assetReturned, title, body: bodyText, targetType: "asset_card", targetId: asset.id, }); dispatchTasks.push({ notifications, type: NOTIFICATION_TYPES.assetReturned, title, body: bodyText, targetType: "asset_card", targetId: asset.id, targetPath: "/assets", actorId: request.employee.id, }); emailTasks.push({ type: NOTIFICATION_TYPES.assetReturned, recipientEmployeeIds: recipients, subject: title, body: bodyText, targetPath: "/assets", }); return { updatedAsset, assignmentId: closedAssignment?.id ?? null }; }); for (const task of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...task, }); } await flushNotificationDispatchTasks({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, tasks: dispatchTasks, }); return sendData(reply, request, { assetId: result.updatedAsset.id, assetNo: result.updatedAsset.asset_no, status: result.updatedAsset.status, assigneeEmployeeId: result.updatedAsset.assignee_employee_id, location: result.updatedAsset.location ?? null, assignmentId: result.assignmentId, }); }, ); fastify.post( "/assets/:id/transfer", { schema: { tags: ["Assets"], summary: "Transfer Asset" }, preHandler: fastify.requirePermission("asset.transfer"), }, async (request, reply) => { const assetId = parseUuid("id", request.params?.id); const body = parseBody(TRANSFER_BODY_SCHEMA, request.body); const note = normalizeNote(body.note); const hasEmployeeField = Object.prototype.hasOwnProperty.call(body, "toEmployeeId"); const hasLocationField = Object.prototype.hasOwnProperty.call(body, "toLocation"); const nextLocation = hasLocationField ? normalizeLocation(body.toLocation) : undefined; const emailTasks = []; const dispatchTasks = []; const result = await fastify.prisma.$transaction(async (tx) => { const asset = await ensureAssetExists(tx, assetId); const currentEmployeeId = asset.assignee_employee_id ?? null; const currentLocation = asset.location ?? null; const requestedEmployeeId = hasEmployeeField ? body.toEmployeeId ?? null : currentEmployeeId; if (hasEmployeeField && body.toEmployeeId == null && currentEmployeeId) { throw buildValidationError({ toEmployeeId: "use_return" }, 409); } const employeeChange = hasEmployeeField && requestedEmployeeId !== currentEmployeeId; const locationChange = hasLocationField && nextLocation !== currentLocation; if (!employeeChange && !locationChange) { throw buildValidationError({ transfer: "no_change" }, 409); } if (employeeChange) { await ensureActiveEmployee(tx, requestedEmployeeId, "toEmployeeId"); } const transferredAt = new Date(); if (employeeChange && currentEmployeeId) { await closeAssignmentIfAny(tx, asset, note, transferredAt); } let assignmentId = null; if (employeeChange && requestedEmployeeId) { const newAssignment = await tx.assetAssignment.create({ data: { asset_id: asset.id, employee_id: requestedEmployeeId, assigned_at: transferredAt, note, }, select: { id: true }, }); assignmentId = newAssignment.id; } const updatedAsset = await tx.assetCard.update({ where: { id: asset.id }, data: { ...(employeeChange ? { assignee_employee_id: requestedEmployeeId } : {}), ...(locationChange ? { location: nextLocation } : {}), ...(employeeChange && requestedEmployeeId ? { status: "in_use" } : {}), updated_by: request.employee.id, }, select: { id: true, asset_no: true, status: true, assignee_employee_id: true, location: true, }, }); const transfer = await tx.assetTransfer.create({ data: { asset_id: asset.id, from_employee_id: currentEmployeeId, to_employee_id: employeeChange ? requestedEmployeeId : currentEmployeeId, from_location: currentLocation, to_location: locationChange ? nextLocation : currentLocation, transferred_at: transferredAt, note, }, select: { id: true }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_TRANSFERRED", targetType: "asset_card", targetId: asset.id, extra: { assetNo: asset.asset_no, fromEmployeeId: currentEmployeeId, toEmployeeId: employeeChange ? requestedEmployeeId : currentEmployeeId, fromLocation: currentLocation, toLocation: locationChange ? nextLocation : currentLocation, transferId: transfer.id, assignmentId, }, }), }); const recipients = uniq([ currentEmployeeId, employeeChange ? requestedEmployeeId : currentEmployeeId, ]); const title = `【资产调拨】资产卡片 ${asset.asset_no}`; const bodyText = `资产卡片 ${asset.asset_no} 已完成调拨,请在资产台账中查看最新归属与位置。`; const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.assetTransferred, title, body: bodyText, targetType: "asset_card", targetId: asset.id, }); dispatchTasks.push({ notifications, type: NOTIFICATION_TYPES.assetTransferred, title, body: bodyText, targetType: "asset_card", targetId: asset.id, targetPath: "/assets", actorId: request.employee.id, }); emailTasks.push({ type: NOTIFICATION_TYPES.assetTransferred, recipientEmployeeIds: recipients, subject: title, body: bodyText, targetPath: "/assets", }); return { updatedAsset, transferId: transfer.id, assignmentId }; }); for (const task of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...task, }); } await flushNotificationDispatchTasks({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, tasks: dispatchTasks, }); return sendData(reply, request, { assetId: result.updatedAsset.id, assetNo: result.updatedAsset.asset_no, status: result.updatedAsset.status, assigneeEmployeeId: result.updatedAsset.assignee_employee_id, location: result.updatedAsset.location ?? null, transferId: result.transferId, assignmentId: result.assignmentId, }); }, ); } module.exports = assetMovementsRoutes;