const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { issueDocumentNumber } = require("../services/documentSequence"); const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications"); const { sendNotificationEmailSafe } = require("../services/email"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { sendData } = require("../utils/reply"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const UUID_SCHEMA = z.string().uuid(); const INVENTORY_SCOPE_TYPES = ["all", "legal_entity", "cost_center", "location"]; const INVENTORY_STATUS_VALUES = ["draft", "counting", "completed"]; const INVENTORY_RESULT_VALUES = ["matched", "missing", "extra", "damaged"]; const LIST_QUERY_SCHEMA = z.object({ status: z.enum(INVENTORY_STATUS_VALUES).optional(), scopeType: z.enum(INVENTORY_SCOPE_TYPES).optional(), }); const CREATE_BODY_SCHEMA = z .object({ scopeType: z.enum(INVENTORY_SCOPE_TYPES), scopeRefId: z.string().trim().max(200).nullable().optional(), }) .strict(); const RECORD_ITEMS_BODY_SCHEMA = z .object({ items: z .array( z .object({ assetId: UUID_SCHEMA, result: z.enum(INVENTORY_RESULT_VALUES), note: z.string().trim().max(500).nullable().optional(), }) .strict(), ) .min(1), }) .strict(); 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 parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function normalizeOptionalText(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))); } async function ensureScopeReference(tx, scopeType, scopeRefId) { if (scopeType === "all") { return null; } if (!scopeRefId) { throw buildValidationError({ scopeRefId: "required" }); } if (scopeType === "location") { return scopeRefId; } const normalizedId = parseUuid("scopeRefId", scopeRefId); if (scopeType === "legal_entity") { const legalEntity = await tx.legalEntity.findUnique({ where: { id: normalizedId }, select: { id: true }, }); if (!legalEntity) { throw buildValidationError({ scopeRefId: "not_found" }, 404); } return normalizedId; } const costCenter = await tx.costCenter.findUnique({ where: { id: normalizedId }, select: { id: true }, }); if (!costCenter) { throw buildValidationError({ scopeRefId: "not_found" }, 404); } return normalizedId; } function mapInventoryItem(record) { return { id: record.id, assetId: record.asset_id, result: record.result, note: record.note ?? null, asset: record.asset ? { id: record.asset.id, assetNo: record.asset.asset_no, name: record.asset.name, status: record.asset.status, legalEntityId: record.asset.legal_entity_id, costCenterId: record.asset.cost_center_id, location: record.asset.location ?? null, assigneeEmployeeId: record.asset.assignee_employee_id ?? null, } : null, }; } function mapInventoryCheck(record) { return { id: record.id, checkNo: record.check_no, scopeType: record.scope_type, scopeRefId: record.scope_ref_id ?? null, status: record.status, startedAt: record.started_at?.toISOString?.() ?? null, completedAt: record.completed_at?.toISOString?.() ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, items: (record.items ?? []).map(mapInventoryItem), }; } async function ensureInventoryCheck(tx, inventoryCheckId) { const record = await tx.inventoryCheck.findUnique({ where: { id: inventoryCheckId }, select: { id: true, check_no: true, scope_type: true, scope_ref_id: true, status: true, started_at: true, completed_at: true, created_at: true, updated_at: true, created_by: true, items: { orderBy: [{ asset: { asset_no: "asc" } }], select: { id: true, asset_id: true, result: true, note: true, asset: { select: { id: true, asset_no: true, name: true, status: true, legal_entity_id: true, cost_center_id: true, location: true, assignee_employee_id: true, }, }, }, }, }, }); if (!record) { throw buildValidationError({ id: "not_found" }, 404); } return record; } function ensureCheckStatus(record, expectedStatus) { if (record.status !== expectedStatus) { throw buildValidationError({ status: `expected_${expectedStatus}` }, 409); } } function ensureAssetInScope(check, asset) { if (check.scope_type === "all") { return; } if (check.scope_type === "legal_entity" && asset.legal_entity_id !== check.scope_ref_id) { throw buildValidationError({ assetId: "scope_mismatch" }, 409); } if (check.scope_type === "cost_center" && asset.cost_center_id !== check.scope_ref_id) { throw buildValidationError({ assetId: "scope_mismatch" }, 409); } if (check.scope_type === "location" && (asset.location ?? null) !== (check.scope_ref_id ?? null)) { throw buildValidationError({ assetId: "scope_mismatch" }, 409); } } async function inventoryChecksRoutes(fastify) { fastify.get( "/inventory-checks", { schema: { tags: ["Assets"], summary: "List Inventory Checks" }, preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { const query = parseListQuery(request.query); const where = {}; if (query.status) { where.status = query.status; } if (query.scopeType) { where.scope_type = query.scopeType; } const items = await fastify.prisma.inventoryCheck.findMany({ where, orderBy: [{ created_at: "desc" }, { id: "desc" }], select: { id: true, check_no: true, scope_type: true, scope_ref_id: true, status: true, started_at: true, completed_at: true, created_at: true, updated_at: true, items: { orderBy: [{ asset: { asset_no: "asc" } }], select: { id: true, asset_id: true, result: true, note: true, asset: { select: { id: true, asset_no: true, name: true, status: true, legal_entity_id: true, cost_center_id: true, location: true, assignee_employee_id: true, }, }, }, }, }, }); return sendData(reply, request, { items: items.map(mapInventoryCheck) }); }, ); fastify.post( "/inventory-checks", { schema: { tags: ["Assets"], summary: "Create Inventory Check" }, preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { const body = parseBody(CREATE_BODY_SCHEMA, request.body); const scopeRefId = normalizeOptionalText(body.scopeRefId); const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const normalizedScopeRefId = await ensureScopeReference(tx, body.scopeType, scopeRefId); const issued = await issueDocumentNumber(tx, { documentType: "inventory_check", actorId: request.employee.id, }); const created = await tx.inventoryCheck.create({ data: { check_no: issued.documentNumber, scope_type: body.scopeType, scope_ref_id: normalizedScopeRefId, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, check_no: true, scope_type: true, scope_ref_id: true, status: true, started_at: true, completed_at: true, created_at: true, updated_at: true, created_by: true, items: { select: { id: true, asset_id: true, result: true, note: true, asset: { select: { id: true, asset_no: true, name: true, status: true, legal_entity_id: true, cost_center_id: true, location: true, assignee_employee_id: true, }, }, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVENTORY_CHECK_CREATED", targetType: "inventory_check", targetId: created.id, extra: { checkNo: created.check_no, scopeType: created.scope_type, scopeRefId: created.scope_ref_id ?? null, }, }), }); return created; }); return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); }, ); fastify.post( "/inventory-checks/:id/start", { schema: { tags: ["Assets"], summary: "Start Inventory Check" }, preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { const inventoryCheckId = parseUuid("id", request.params?.id); const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const current = await ensureInventoryCheck(tx, inventoryCheckId); ensureCheckStatus(current, "draft"); const updated = await tx.inventoryCheck.update({ where: { id: inventoryCheckId }, data: { status: "counting", started_at: new Date(), updated_by: request.employee.id, }, select: { id: true, check_no: true, scope_type: true, scope_ref_id: true, status: true, started_at: true, completed_at: true, created_at: true, updated_at: true, created_by: true, items: { orderBy: [{ asset: { asset_no: "asc" } }], select: { id: true, asset_id: true, result: true, note: true, asset: { select: { id: true, asset_no: true, name: true, status: true, legal_entity_id: true, cost_center_id: true, location: true, assignee_employee_id: true, }, }, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVENTORY_CHECK_STARTED", targetType: "inventory_check", targetId: updated.id, extra: { checkNo: updated.check_no, }, }), }); return updated; }); return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); }, ); fastify.post( "/inventory-checks/:id/items", { schema: { tags: ["Assets"], summary: "Record Inventory Check Items" }, preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { const inventoryCheckId = parseUuid("id", request.params?.id); const body = parseBody(RECORD_ITEMS_BODY_SCHEMA, request.body); const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const current = await ensureInventoryCheck(tx, inventoryCheckId); ensureCheckStatus(current, "counting"); const seen = new Set(); for (const item of body.items) { if (seen.has(item.assetId)) { throw buildValidationError({ items: "duplicate_asset_id" }, 409); } seen.add(item.assetId); const asset = await tx.assetCard.findUnique({ where: { id: item.assetId }, select: { id: true, asset_no: true, name: true, status: true, legal_entity_id: true, cost_center_id: true, location: true, assignee_employee_id: true, }, }); if (!asset) { throw buildValidationError({ assetId: "not_found" }, 404); } ensureAssetInScope(current, asset); await tx.inventoryCheckItem.upsert({ where: { inventory_check_id_asset_id: { inventory_check_id: inventoryCheckId, asset_id: item.assetId, }, }, update: { result: item.result, note: normalizeOptionalText(item.note), }, create: { inventory_check_id: inventoryCheckId, asset_id: item.assetId, result: item.result, note: normalizeOptionalText(item.note), }, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVENTORY_ITEM_RECORDED", targetType: "inventory_check", targetId: inventoryCheckId, extra: { itemCount: body.items.length, assetIds: body.items.map((item) => item.assetId), }, }), }); return ensureInventoryCheck(tx, inventoryCheckId); }); return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); }, ); fastify.post( "/inventory-checks/:id/complete", { schema: { tags: ["Assets"], summary: "Complete Inventory Check" }, preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { const inventoryCheckId = parseUuid("id", request.params?.id); const emailTasks = []; const dispatchTasks = []; const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const current = await ensureInventoryCheck(tx, inventoryCheckId); ensureCheckStatus(current, "counting"); const updated = await tx.inventoryCheck.update({ where: { id: inventoryCheckId }, data: { status: "completed", completed_at: new Date(), updated_by: request.employee.id, }, select: { id: true, check_no: true, scope_type: true, scope_ref_id: true, status: true, started_at: true, completed_at: true, created_at: true, updated_at: true, created_by: true, items: { orderBy: [{ asset: { asset_no: "asc" } }], select: { id: true, asset_id: true, result: true, note: true, asset: { select: { id: true, asset_no: true, name: true, status: true, legal_entity_id: true, cost_center_id: true, location: true, assignee_employee_id: true, }, }, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVENTORY_CHECK_COMPLETED", targetType: "inventory_check", targetId: updated.id, extra: { checkNo: updated.check_no, itemCount: updated.items.length, }, }), }); const financeMgrRecipients = await resolveRecipientsForAssignee(tx, { assigneeRole: "finance_mgr" }); const recipients = uniq([updated.created_by, ...financeMgrRecipients]); const title = `【盘点完成】盘点单 ${updated.check_no}`; const bodyText = `盘点单 ${updated.check_no} 已完成,请在资产盘点中查看结果。`; const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.inventoryCheckCompleted, title, body: bodyText, targetType: "inventory_check", targetId: updated.id, }); dispatchTasks.push({ notifications, type: NOTIFICATION_TYPES.inventoryCheckCompleted, title, body: bodyText, targetType: "inventory_check", targetId: updated.id, targetPath: "/inventory-checks", actorId: request.employee.id, }); emailTasks.push({ type: NOTIFICATION_TYPES.inventoryCheckCompleted, recipientEmployeeIds: recipients, subject: title, body: bodyText, targetPath: "/inventory-checks", }); return updated; }); 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, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); }, ); } module.exports = inventoryChecksRoutes;