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 ASSET_TYPE_VALUES = ["fixed_asset", "electronic_document", "electronic_system", "code_repository", "backup_media"]; const ASSET_SENSITIVITY_VALUES = ["public_candidate", "internal", "confidential", "strictly_confidential"]; const ASSET_DOCUMENT_CATEGORY_BY_TYPE = { fixed_asset: "ASSET_CARD_DEFAULT", electronic_document: "ASSET_EA_DOC", electronic_system: "ASSET_EA_SYS", code_repository: "ASSET_EA_REPO", backup_media: "ASSET_EA_BKP", }; 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(), assetType: z.enum(ASSET_TYPE_VALUES).optional(), sensitivityLevel: z.enum(ASSET_SENSITIVITY_VALUES).optional(), categoryId: UUID_SCHEMA.optional(), assigneeId: UUID_SCHEMA.optional(), ownerId: UUID_SCHEMA.optional(), keeperId: 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), assetType: z.enum(ASSET_TYPE_VALUES).optional(), sensitivityLevel: z.enum(ASSET_SENSITIVITY_VALUES).optional(), 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(), ownerEmployeeId: UUID_SCHEMA.nullable().optional(), keeperEmployeeId: UUID_SCHEMA.nullable().optional(), archiveLocation: z.string().trim().max(500).nullable().optional(), externalRef: z.string().trim().max(500).nullable().optional(), sourceHash: z.string().trim().max(128).nullable().optional(), verificationNo: z.string().trim().regex(/^VER-\d{5,}$/).max(32).nullable().optional(), confidentialityNote: z.string().trim().max(1000).nullable().optional(), metadata: z.record(z.string(), z.unknown()).nullable().optional(), lastVerifiedAt: DATE_ONLY_SCHEMA.nullable().optional(), expiresAt: DATE_ONLY_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, assetType: true }).partial(); const REDACTED_DOC_IMPORT_SCHEMA = z .object({ dryRun: z.boolean().optional(), write: z.boolean().optional(), categoryId: UUID_SCHEMA.optional(), legalEntityId: UUID_SCHEMA.optional(), costCenterId: UUID_SCHEMA.optional(), ownerEmployeeId: UUID_SCHEMA.nullable().optional(), keeperEmployeeId: UUID_SCHEMA.nullable().optional(), items: z .array( z .object({ code: z.string().trim().max(64).optional(), name: z.string().trim().min(1).max(200), sourceHash: z.string().trim().max(128).nullable().optional(), sensitivityLevel: z.enum(ASSET_SENSITIVITY_VALUES).optional(), externalRef: z.string().trim().max(500).nullable().optional(), archiveLocation: z.string().trim().max(500).nullable().optional(), docType: z.string().trim().max(80).nullable().optional(), extension: z.string().trim().max(32).nullable().optional(), detectedCategories: z.string().trim().max(500).nullable().optional(), requiredRedaction: z.string().trim().max(1000).nullable().optional(), verificationNo: z.string().trim().regex(/^VER-\d{5,}$/).max(32).nullable().optional(), }) .strict(), ) .min(1) .max(20), }) .strict(); 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, asset_type: true, sensitivity_level: 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, owner_employee_id: true, keeper_employee_id: true, archive_location: true, external_ref: true, source_hash: true, verification_no: true, confidentiality_note: true, metadata: true, last_verified_at: true, expires_at: 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, }, }, }, }, owner: { select: { id: true, employee_no: true, name: true, department: { select: { id: true, name: true, }, }, }, }, keeper: { 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, assetType: parsed.data.assetType, sensitivityLevel: parsed.data.sensitivityLevel, categoryId: parsed.data.categoryId, assigneeId: parsed.data.assigneeId, ownerId: parsed.data.ownerId, keeperId: parsed.data.keeperId, 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, assetType: body.assetType, sensitivityLevel: body.sensitivityLevel, 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, ownerEmployeeId: Object.prototype.hasOwnProperty.call(body, "ownerEmployeeId") ? body.ownerEmployeeId ?? null : undefined, keeperEmployeeId: Object.prototype.hasOwnProperty.call(body, "keeperEmployeeId") ? body.keeperEmployeeId ?? null : undefined, archiveLocation: Object.prototype.hasOwnProperty.call(body, "archiveLocation") ? body.archiveLocation?.trim() ? body.archiveLocation.trim() : null : undefined, externalRef: Object.prototype.hasOwnProperty.call(body, "externalRef") ? body.externalRef?.trim() ? body.externalRef.trim() : null : undefined, sourceHash: Object.prototype.hasOwnProperty.call(body, "sourceHash") ? body.sourceHash?.trim() ? body.sourceHash.trim() : null : undefined, verificationNo: Object.prototype.hasOwnProperty.call(body, "verificationNo") ? body.verificationNo?.trim() ? body.verificationNo.trim().toUpperCase() : null : undefined, confidentialityNote: Object.prototype.hasOwnProperty.call(body, "confidentialityNote") ? body.confidentialityNote?.trim() ? body.confidentialityNote.trim() : null : undefined, metadata: Object.prototype.hasOwnProperty.call(body, "metadata") ? body.metadata ?? null : undefined, lastVerifiedAt: Object.prototype.hasOwnProperty.call(body, "lastVerifiedAt") ? body.lastVerifiedAt ?? null : undefined, expiresAt: Object.prototype.hasOwnProperty.call(body, "expiresAt") ? body.expiresAt ?? null : undefined, activatedAt: Object.prototype.hasOwnProperty.call(body, "activatedAt") ? body.activatedAt ?? null : undefined, retiredAt: Object.prototype.hasOwnProperty.call(body, "retiredAt") ? body.retiredAt ?? null : undefined, }; } const SAFE_HASH_PATTERN = /^[A-Za-z0-9:_-]+$/; const FORBIDDEN_ASSET_TEXT_PATTERNS = [ { code: "secret_assignment", pattern: /(?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|private[_-]?key|encodingaeskey)\s*[:=]/i }, { code: "secret_env_name", pattern: /\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PRIVATE_KEY|ENCODINGAESKEY)[A-Z0-9_]*\b/ }, { code: "secret_cn_assignment", pattern: /(?:密码|密钥|私钥|令牌)\s*[::=]/ }, { code: "bearer_token", pattern: /\bBearer\s+[A-Za-z0-9._-]{10,}/i }, { code: "private_key_material", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i }, { code: "local_absolute_path", pattern: /(^|[\s"'((,,;;])(?:\/(?:home|root|Users|var|etc|mnt|Volumes|app)\/|[A-Za-z]:[\\/]|\\\\)/i }, { code: "raw_payload", pattern: /(原始\s*(?:payload|xml|报文|正文|表单)|raw\s*(?:payload|xml|body|form))/i }, ]; function findUnsafeAssetText(value) { if (typeof value !== "string" || !value.trim()) { return null; } if (value.length > 1500) { return "too_long"; } const matched = FORBIDDEN_ASSET_TEXT_PATTERNS.find((rule) => rule.pattern.test(value)); return matched?.code ?? null; } function validateSourceHash(fieldName, value, details) { if (typeof value === "string" && value && !SAFE_HASH_PATTERN.test(value)) { details[fieldName] = "unsafe_hash_format"; } } function validateSafeTextField(fieldName, value, details) { const unsafeReason = findUnsafeAssetText(value); if (unsafeReason) { details[fieldName] = unsafeReason; } } function validateMetadataField(fieldName, value, details) { if (value == null) { return; } const serialized = JSON.stringify(value); if (serialized.length > 2000) { details[fieldName] = "too_large"; return; } validateSafeTextField(fieldName, serialized, details); } function validateAssetTextBoundaries(payload) { const details = {}; validateSafeTextField("name", payload.name, details); validateSafeTextField("archiveLocation", payload.archiveLocation, details); validateSafeTextField("externalRef", payload.externalRef, details); validateSafeTextField("confidentialityNote", payload.confidentialityNote, details); validateSourceHash("sourceHash", payload.sourceHash, details); validateMetadataField("metadata", payload.metadata, details); if (Object.keys(details).length > 0) { throw buildValidationError(details); } } function validateRedactedImportBoundaries(payload) { const details = {}; for (const [index, item] of payload.items.entries()) { validateSafeTextField(`items.${index}.name`, item.name, details); validateSafeTextField(`items.${index}.archiveLocation`, item.archiveLocation, details); validateSafeTextField(`items.${index}.externalRef`, item.externalRef, details); validateSafeTextField(`items.${index}.detectedCategories`, item.detectedCategories, details); validateSafeTextField(`items.${index}.requiredRedaction`, item.requiredRedaction, details); validateSourceHash(`items.${index}.sourceHash`, item.sourceHash, details); } if (Object.keys(details).length > 0) { throw buildValidationError(details); } } 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; } const employeeId = request.employee.id; if ( assetRecord.assignee_employee_id !== employeeId && assetRecord.owner_employee_id !== employeeId && assetRecord.keeper_employee_id !== employeeId ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "asset.view_own" }, }); } } function isAssetRelatedEmployee(request, record) { const employeeId = request.employee?.id; return Boolean( employeeId && (record.assignee_employee_id === employeeId || record.owner_employee_id === employeeId || record.keeper_employee_id === employeeId), ); } function canViewAssetSensitiveFields(fastify, request, record) { if (canManageAssets(fastify, request.employee) || isAssetRelatedEmployee(request, record)) { return true; } if (record.asset_type && record.asset_type !== "fixed_asset") { return false; } return !["confidential", "strictly_confidential"].includes(record.sensitivity_level); } 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 mapEmployeeRef(record) { if (!record) { return null; } return { id: record.id, employeeNo: record.employee_no, name: record.name, department: record.department ? { id: record.department.id, name: record.department.name, } : null, }; } function mapAsset(record, request, fastify) { const canViewSensitiveFields = canViewAssetSensitiveFields(fastify, request, record); return { id: record.id, assetNo: record.asset_no, name: record.name, assetType: record.asset_type, sensitivityLevel: record.sensitivity_level, 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, ownerEmployeeId: record.owner_employee_id ?? null, keeperEmployeeId: record.keeper_employee_id ?? null, archiveLocation: canViewSensitiveFields ? record.archive_location ?? null : null, externalRef: canViewSensitiveFields ? record.external_ref ?? null : null, sourceHash: canViewSensitiveFields ? record.source_hash ?? null : null, verificationNo: record.verification_no ?? null, confidentialityNote: canViewSensitiveFields ? record.confidentiality_note ?? null : null, metadata: canViewSensitiveFields ? record.metadata ?? null : null, sensitiveFieldsRedacted: !canViewSensitiveFields, lastVerifiedAt: record.last_verified_at?.toISOString?.().slice(0, 10) ?? null, expiresAt: record.expires_at?.toISOString?.().slice(0, 10) ?? 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: mapEmployeeRef(record.assignee), owner: mapEmployeeRef(record.owner), keeper: mapEmployeeRef(record.keeper), 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 ensureActiveEmployee(tx, fieldName, employeeId) { if (!employeeId) { return null; } const record = await tx.employee.findUnique({ where: { id: employeeId }, select: { id: true, employee_no: true, name: true, status: true, }, }); if (!record) { throw buildValidationError({ [fieldName]: "not_found" }, 404); } if (record.status !== "active") { throw buildValidationError({ [fieldName]: "inactive" }, 409); } return record; } async function ensureAssigneeEmployee(tx, assigneeEmployeeId) { return ensureActiveEmployee(tx, "assigneeEmployeeId", assigneeEmployeeId); } function assetDocumentCategoryCode(assetType, category = null) { if (assetType === "fixed_asset" && category?.document_category?.code) { return category.document_category.code; } return ASSET_DOCUMENT_CATEGORY_BY_TYPE[assetType] ?? ASSET_DOCUMENT_CATEGORY_BY_TYPE.fixed_asset; } function assetSequenceKey(assetType, documentCategoryCode) { return `asset_card:${documentCategoryCode || assetDocumentCategoryCode(assetType)}`; } 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 runAssetLedgerSummaryErpSyncSafely({ prisma, config, request, actorId, asset, sourceEventName, changeType, changedFields, }) { try { return await handleAssetLedgerSummaryErpSync({ tx: prisma, config, request, actorId, asset, sourceEventName, changeType, changedFields, }); } catch (error) { request.log.error( { err: error, assetId: asset?.id ?? null, assetNo: asset?.asset_no ?? null, sourceEventName, changeType, changedFields: Array.isArray(changedFields) ? changedFields : [], }, "Asset ledger summary ERP sync failed after asset commit", ); return null; } } 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.assetType) { baseWhere.asset_type = query.assetType; } if (query.sensitivityLevel) { baseWhere.sensitivity_level = query.sensitivityLevel; } 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.OR = [ { assignee_employee_id: request.employee.id }, { owner_employee_id: request.employee.id }, { keeper_employee_id: request.employee.id }, ]; } else if (query.assigneeId) { baseWhere.assignee_employee_id = query.assigneeId; } if (!mine && query.ownerId) { baseWhere.owner_employee_id = query.ownerId; } if (!mine && query.keeperId) { baseWhere.keeper_employee_id = query.keeperId; } const listOptions = { orderBy: [{ updated_at: "desc" }, { created_at: "desc" }], select: ASSET_CARD_SELECT, }; let items; if (query.q) { const searchConditions = [ { asset_no: query.q }, { serial_no: query.q }, { verification_no: query.q.toUpperCase() }, { name: { contains: query.q, mode: "insensitive" } }, ]; if (canManageAssets(fastify, request.employee) || mine) { searchConditions.splice(3, 0, { source_hash: query.q }); searchConditions.splice(4, 0, { external_ref: { contains: query.q, mode: "insensitive" } }); searchConditions.splice(5, 0, { archive_location: { contains: query.q, mode: "insensitive" } }); } const searchWhere = { OR: searchConditions, }; items = await fastify.prisma.assetCard.findMany({ where: { AND: [baseWhere, searchWhere] }, ...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)); validateAssetTextBoundaries(payload); 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); await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null); await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null); await ensureApprovedPurchaseRequest(tx, payload.purchaseRequestId ?? null); const assetType = payload.assetType ?? "fixed_asset"; const documentCategoryCode = assetDocumentCategoryCode(assetType, category); const issued = await issueDocumentNumber(tx, { documentType: "asset_card", documentCategoryCode, sequenceKey: assetSequenceKey(assetType, documentCategoryCode), 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, asset_type: assetType, sensitivity_level: payload.sensitivityLevel ?? "internal", 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, owner_employee_id: payload.ownerEmployeeId ?? null, keeper_employee_id: payload.keeperEmployeeId ?? null, archive_location: payload.archiveLocation ?? null, external_ref: payload.externalRef ?? null, source_hash: payload.sourceHash ?? null, verification_no: payload.verificationNo ?? null, confidentiality_note: payload.confidentialityNote ?? null, metadata: payload.metadata ?? null, last_verified_at: payload.lastVerifiedAt ? parseDateOnlyString(payload.lastVerifiedAt) : null, expires_at: payload.expiresAt ? parseDateOnlyString(payload.expiresAt) : 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, }, }); } return created; }); void fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_CARD_CREATED", targetType: "asset_card", targetId: asset.id, extra: { assetNo: asset.asset_no, assetType: asset.asset_type, sensitivityLevel: asset.sensitivity_level, purchaseRequestId: asset.purchase_request_id ?? null, assigneeEmployeeId: asset.assignee_employee_id ?? null, ownerEmployeeId: asset.owner_employee_id ?? null, keeperEmployeeId: asset.keeper_employee_id ?? null, }, }), }).catch((error) => { fastify.log.error({ err: error, assetId: asset.id }, "Failed to write asset create audit log"); }); await runAssetLedgerSummaryErpSyncSafely({ prisma: fastify.prisma, config: fastify.config, request, actorId: request.employee.id, asset, sourceEventName: 'asset.card.created', changeType: 'created', changedFields: [], }); return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); }, ); fastify.post( "/assets/import/redacted-doc-index", { schema: { tags: ["Assets"], summary: "Import Redacted Electronic Document Asset Index" }, preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { const payload = parseCreateBody(REDACTED_DOC_IMPORT_SCHEMA, request.body); validateRedactedImportBoundaries(payload); const shouldWrite = payload.write === true && payload.dryRun !== true; if (shouldWrite && process.env.ASSET_INDEX_IMPORT_WRITE !== "1") { throw new AppError({ code: "CONFIG_001", message: "资产索引写入开关未开启", statusCode: 409, details: { requiredEnv: "ASSET_INDEX_IMPORT_WRITE=1" }, }); } if (shouldWrite && (!payload.categoryId || !payload.legalEntityId || !payload.costCenterId)) { throw buildValidationError({ categoryId: payload.categoryId ? undefined : "required_for_write", legalEntityId: payload.legalEntityId ? undefined : "required_for_write", costCenterId: payload.costCenterId ? undefined : "required_for_write", }); } const preview = payload.items.map((item, index) => ({ index, code: item.code ?? null, name: item.name, sourceHash: item.sourceHash ?? null, sensitivityLevel: item.sensitivityLevel ?? "confidential", action: shouldWrite ? "create_or_skip" : "dry_run", })); if (!shouldWrite) { return sendData(reply, request, { mode: "dry_run", readOnly: true, imported: 0, items: preview, boundaries: { noFileUpload: true, noRawContent: true, noFullPathOutput: true, }, }); } const result = 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 ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null); await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null); const items = []; for (const [index, item] of payload.items.entries()) { const existing = item.sourceHash ? await tx.assetCard.findFirst({ where: { source_hash: item.sourceHash, asset_type: "electronic_document" }, select: { id: true, asset_no: true }, }) : null; if (existing) { items.push({ index, code: item.code ?? null, status: "skipped_existing_hash", assetId: existing.id, assetNo: existing.asset_no, sourceHash: item.sourceHash ?? null, }); continue; } const issued = await issueDocumentNumber(tx, { documentType: "asset_card", documentCategoryCode: assetDocumentCategoryCode("electronic_document"), sequenceKey: assetSequenceKey("electronic_document", assetDocumentCategoryCode("electronic_document")), actorId: request.employee.id, }); const created = await tx.assetCard.create({ data: { asset_no: issued.documentNumber, name: item.name.trim(), asset_type: "electronic_document", sensitivity_level: item.sensitivityLevel ?? "confidential", category_id: payload.categoryId, legal_entity_id: payload.legalEntityId, cost_center_id: payload.costCenterId, status: "archived", owner_employee_id: payload.ownerEmployeeId ?? null, keeper_employee_id: payload.keeperEmployeeId ?? null, archive_location: item.archiveLocation ?? null, external_ref: item.code ?? item.externalRef ?? null, source_hash: item.sourceHash ?? null, verification_no: item.verificationNo ?? null, confidentiality_note: item.requiredRedaction ?? null, metadata: { source: "redacted_company_index", docType: item.docType ?? null, extension: item.extension ?? null, detectedCategories: item.detectedCategories ?? null, rawFileImported: false, }, last_verified_at: new Date(), created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, asset_no: true }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_REDACTED_DOC_INDEX_IMPORTED", targetType: "asset_card", targetId: created.id, extra: { assetNo: created.asset_no, code: item.code ?? null, sourceHash: item.sourceHash ?? null, rawFileImported: false, }, }), }); items.push({ index, code: item.code ?? null, status: "created", assetId: created.id, assetNo: created.asset_no, sourceHash: item.sourceHash ?? null, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ASSET_REDACTED_DOC_INDEX_IMPORT_BATCH", targetType: "asset_card", targetId: null, extra: { itemCount: payload.items.length, createdCount: items.filter((item) => item.status === "created").length, categoryId: category.id, rawFileImported: false, }, }), }); return items; }); return sendData(reply, request, { mode: "write", readOnly: false, imported: result.filter((item) => item.status === "created").length, items: result, boundaries: { noFileUpload: true, noRawContent: true, noFullPathOutput: true, }, }); }, ); 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)); validateAssetTextBoundaries(payload); const { asset, assetLedgerSummaryChange } = await fastify.prisma.$transaction(async (tx) => { const current = await tx.assetCard.findUnique({ where: { id: assetId }, select: { id: true, asset_no: true, name: true, asset_type: true, sensitivity_level: 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, owner_employee_id: true, keeper_employee_id: true, archive_location: true, external_ref: true, source_hash: true, verification_no: true, confidentiality_note: true, metadata: true, last_verified_at: true, expires_at: 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 }); } await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null); await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null); 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.assetType !== undefined ? { asset_type: payload.assetType } : {}), ...(payload.sensitivityLevel !== undefined ? { sensitivity_level: payload.sensitivityLevel } : {}), ...(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.ownerEmployeeId !== undefined ? { owner_employee_id: payload.ownerEmployeeId } : {}), ...(payload.keeperEmployeeId !== undefined ? { keeper_employee_id: payload.keeperEmployeeId } : {}), ...(payload.archiveLocation !== undefined ? { archive_location: payload.archiveLocation } : {}), ...(payload.externalRef !== undefined ? { external_ref: payload.externalRef } : {}), ...(payload.sourceHash !== undefined ? { source_hash: payload.sourceHash } : {}), ...(payload.verificationNo !== undefined ? { verification_no: payload.verificationNo } : {}), ...(payload.confidentialityNote !== undefined ? { confidentiality_note: payload.confidentialityNote } : {}), ...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}), ...(payload.lastVerifiedAt !== undefined ? { last_verified_at: payload.lastVerifiedAt ? parseDateOnlyString(payload.lastVerifiedAt) : null } : {}), ...(payload.expiresAt !== undefined ? { expires_at: payload.expiresAt ? parseDateOnlyString(payload.expiresAt) : null } : {}), ...(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, assetType: updated.asset_type, sensitivityLevel: updated.sensitivity_level, status: updated.status, assigneeEmployeeId: updated.assignee_employee_id ?? null, ownerEmployeeId: updated.owner_employee_id ?? null, keeperEmployeeId: updated.keeper_employee_id ?? null, }, }), }); const assetLedgerSummaryChange = resolveAssetLedgerSummaryChange(current, updated); return { asset: updated, assetLedgerSummaryChange, }; }); if (assetLedgerSummaryChange) { await runAssetLedgerSummaryErpSyncSafely({ prisma: fastify.prisma, config: fastify.config, request, actorId: request.employee.id, asset, sourceEventName: assetLedgerSummaryChange.sourceEventName, changeType: assetLedgerSummaryChange.changeType, changedFields: assetLedgerSummaryChange.changedFields, }); } return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); }, ); } module.exports = assetsRoutes; module.exports.__testables = { handleAssetLedgerSummaryErpSync, parseAssetCreateBodyForTest(body) { return normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, body)); }, };