feat(G2-1,G2-2,G2-3): 扩展预算编号与工作流跨域桥接
这个提交包含在:
@@ -0,0 +1,341 @@
|
||||
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: "purchase_general",
|
||||
preferLegacySequence: false,
|
||||
fallback: {
|
||||
prefix: "CG",
|
||||
dateFormat: "YYYYMMDD",
|
||||
separator: "-",
|
||||
numberLength: 4,
|
||||
resetPeriod: "yearly",
|
||||
},
|
||||
},
|
||||
asset_card: {
|
||||
defaultCategoryCode: "asset_it",
|
||||
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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户