const { AppError } = require("../utils/AppError"); const { compactDateString } = require("../utils/date"); const DOCUMENT_TYPE_ALIASES = { expense: "reimbursement", }; const DOCUMENT_TYPE_CONFIG = { reimbursement: { defaultCategoryCode: "reimbursement_general", preferLegacySequence: true, fallback: { prefix: "BX", dateFormat: "YYYYMMDD", separator: "-", numberLength: 4, resetPeriod: "none", }, }, leave: { defaultCategoryCode: "leave_request", preferLegacySequence: false, fallback: { prefix: "JQ", dateFormat: "YYYYMMDD", separator: "-", numberLength: 4, resetPeriod: "yearly", }, }, purchase_request: { defaultCategoryCode: "PR_STANDARD", preferLegacySequence: false, fallback: { prefix: "CG", dateFormat: "YYYYMMDD", separator: "-", numberLength: 4, resetPeriod: "yearly", }, }, asset_card: { defaultCategoryCode: "ASSET_CARD_DEFAULT", preferLegacySequence: false, fallback: { prefix: "ZC", dateFormat: "YYYYMMDD", separator: "-", numberLength: 4, resetPeriod: "yearly", }, }, general: { defaultCategoryCode: "general_record", preferLegacySequence: false, fallback: { prefix: "GEN", dateFormat: "YYYYMMDD", separator: "-", numberLength: 4, resetPeriod: "yearly", }, }, }; function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function normalizeDocumentType(documentType) { const rawValue = String(documentType ?? "").trim().toLowerCase(); const normalized = DOCUMENT_TYPE_ALIASES[rawValue] ?? rawValue; if (!DOCUMENT_TYPE_CONFIG[normalized]) { throw buildValidationError({ documentType: "unsupported_document_type" }, 409); } return normalized; } function mapSequenceRecord(record) { if (!record) { return null; } return { documentType: record.document_type, prefix: record.prefix, dateFormat: record.date_format, separator: record.separator, numberLength: record.number_length, resetPeriod: record.reset_period, currentNumber: record.current_number, lastResetDate: record.last_reset_date?.toISOString?.()?.slice(0, 10) ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, updatedBy: record.updated_by ?? null, }; } function buildConfigFromFallback(documentType) { const fallback = DOCUMENT_TYPE_CONFIG[documentType].fallback; return { exists: false, source: "fallback", documentType, documentCategoryCode: null, currentNumber: 0, lastResetDate: null, updatedAt: null, updatedBy: null, ...fallback, }; } function buildConfigFromCategory(documentType, category, sequenceRecord) { const fallback = DOCUMENT_TYPE_CONFIG[documentType].fallback; const mappedSequence = mapSequenceRecord(sequenceRecord); return { exists: Boolean(sequenceRecord), source: "document_category", documentType, documentCategoryCode: category.code, prefix: category.sequence_prefix, dateFormat: mappedSequence?.dateFormat ?? fallback.dateFormat, separator: mappedSequence?.separator ?? fallback.separator, numberLength: category.number_length, resetPeriod: category.reset_period, currentNumber: mappedSequence?.currentNumber ?? 0, lastResetDate: mappedSequence?.lastResetDate ?? null, updatedAt: mappedSequence?.updatedAt ?? null, updatedBy: mappedSequence?.updatedBy ?? null, }; } async function findDocumentCategory(prisma, { documentType, documentCategoryCode }) { const normalizedDocumentType = normalizeDocumentType(documentType); const defaultCategoryCode = DOCUMENT_TYPE_CONFIG[normalizedDocumentType].defaultCategoryCode; if (documentCategoryCode) { const category = await prisma.documentCategory.findUnique({ where: { code: documentCategoryCode }, select: { code: true, entity_type: true, sequence_prefix: true, number_length: true, reset_period: true, enabled: true, archived_at: true, }, }); if (!category) { throw buildValidationError({ documentCategoryCode: "not_found" }, 409); } if (category.entity_type !== normalizedDocumentType) { throw buildValidationError({ documentCategoryCode: "entity_type_mismatch" }, 409); } if (!category.enabled || category.archived_at) { throw buildValidationError({ documentCategoryCode: "disabled_or_archived" }, 409); } return category; } if (defaultCategoryCode) { const defaultCategory = await prisma.documentCategory.findUnique({ where: { code: defaultCategoryCode }, select: { code: true, entity_type: true, sequence_prefix: true, number_length: true, reset_period: true, enabled: true, archived_at: true, }, }); if ( defaultCategory && defaultCategory.entity_type === normalizedDocumentType && defaultCategory.enabled && !defaultCategory.archived_at ) { return defaultCategory; } } return prisma.documentCategory.findFirst({ where: { entity_type: normalizedDocumentType, enabled: true, archived_at: null, }, orderBy: [{ created_at: "asc" }, { code: "asc" }], select: { code: true, entity_type: true, sequence_prefix: true, number_length: true, reset_period: true, enabled: true, archived_at: true, }, }); } async function resolveDocumentSequenceConfig(prisma, { documentType, documentCategoryCode = null } = {}) { const normalizedDocumentType = normalizeDocumentType(documentType); const documentTypeConfig = DOCUMENT_TYPE_CONFIG[normalizedDocumentType]; const sequenceRecord = await prisma.documentSequence.findUnique({ where: { document_type: normalizedDocumentType }, select: { document_type: true, prefix: true, date_format: true, separator: true, number_length: true, reset_period: true, current_number: true, last_reset_date: true, updated_at: true, updated_by: true, }, }); if (documentTypeConfig.preferLegacySequence && !documentCategoryCode && sequenceRecord) { return { exists: true, source: "document_sequence", documentCategoryCode: null, ...mapSequenceRecord(sequenceRecord), }; } const category = await findDocumentCategory(prisma, { documentType: normalizedDocumentType, documentCategoryCode, }); if (category) { return buildConfigFromCategory(normalizedDocumentType, category, sequenceRecord); } if (sequenceRecord) { return { exists: true, source: "document_sequence", documentCategoryCode: null, ...mapSequenceRecord(sequenceRecord), }; } return buildConfigFromFallback(normalizedDocumentType); } function formatDocumentNumber(sequenceConfig, timeZone) { const datePart = compactDateString(timeZone); const serial = String(sequenceConfig.current_number).padStart(sequenceConfig.number_length, "0"); return `${sequenceConfig.prefix}${sequenceConfig.separator}${datePart}${sequenceConfig.separator}${serial}`; } async function issueDocumentNumber( tx, { documentType, documentCategoryCode = null, actorId = null, timeZone = "Asia/Shanghai" } = {}, ) { const normalizedDocumentType = normalizeDocumentType(documentType); const config = await resolveDocumentSequenceConfig(tx, { documentType: normalizedDocumentType, documentCategoryCode, }); const sequenceRecord = await tx.documentSequence.upsert({ where: { document_type: normalizedDocumentType }, update: { prefix: config.prefix, date_format: config.dateFormat, separator: config.separator, number_length: config.numberLength, reset_period: config.resetPeriod, current_number: { increment: 1 }, updated_by: actorId, }, create: { document_type: normalizedDocumentType, prefix: config.prefix, date_format: config.dateFormat, separator: config.separator, number_length: config.numberLength, reset_period: config.resetPeriod, current_number: 1, updated_by: actorId, }, select: { document_type: true, prefix: true, date_format: true, separator: true, number_length: true, reset_period: true, current_number: true, last_reset_date: true, updated_at: true, updated_by: true, }, }); const mappedSequence = mapSequenceRecord(sequenceRecord); return { documentNumber: formatDocumentNumber( { prefix: mappedSequence.prefix, separator: mappedSequence.separator, number_length: mappedSequence.numberLength, current_number: mappedSequence.currentNumber, }, timeZone, ), sequence: mappedSequence, config: { ...config, currentNumber: mappedSequence.currentNumber, updatedAt: mappedSequence.updatedAt, updatedBy: mappedSequence.updatedBy, }, }; } module.exports = { normalizeDocumentType, resolveDocumentSequenceConfig, issueDocumentNumber, };