feat(G2-1,G2-2,G2-3): 扩展预算编号与工作流跨域桥接

这个提交包含在:
2026-03-07 18:59:44 +08:00
父节点 09b6f4fbb0
当前提交 52558f22c3
共修改 12 个文件,包含 1211 行新增和 175 行删除
+175
查看文件
@@ -0,0 +1,175 @@
const { AppError } = require("../utils/AppError");
const BUDGET_SCOPE_TYPES = Object.freeze({
global: "global",
departmentLegacy: "department_legacy",
costCenterScoped: "cost_center_scoped",
});
const BUDGET_SCOPE_PRIORITY = [
BUDGET_SCOPE_TYPES.costCenterScoped,
BUDGET_SCOPE_TYPES.departmentLegacy,
BUDGET_SCOPE_TYPES.global,
];
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function resolveBudgetScope({ departmentId = null, legalEntityId = null, costCenterId = null } = {}) {
const hasDepartment = Boolean(departmentId);
const hasLegalEntity = Boolean(legalEntityId);
const hasCostCenter = Boolean(costCenterId);
if (hasCostCenter && !hasLegalEntity) {
throw buildValidationError({ costCenterId: "legalEntityId_required" });
}
if (hasDepartment && (hasLegalEntity || hasCostCenter)) {
throw buildValidationError({ scope: "mixed_dimensions_not_allowed" }, 409);
}
if (hasLegalEntity && !hasCostCenter) {
throw buildValidationError({ costCenterId: "required_with_legalEntityId" });
}
if (hasLegalEntity && hasCostCenter) {
return {
scopeType: BUDGET_SCOPE_TYPES.costCenterScoped,
departmentId: null,
legalEntityId,
costCenterId,
};
}
if (hasDepartment) {
return {
scopeType: BUDGET_SCOPE_TYPES.departmentLegacy,
departmentId,
legalEntityId: null,
costCenterId: null,
};
}
return {
scopeType: BUDGET_SCOPE_TYPES.global,
departmentId: null,
legalEntityId: null,
costCenterId: null,
};
}
function resolveBudgetScopeType(record) {
const hasDepartment = Boolean(record?.department_id);
const hasLegalEntity = Boolean(record?.legal_entity_id);
const hasCostCenter = Boolean(record?.cost_center_id);
if (hasLegalEntity && hasCostCenter && !hasDepartment) {
return BUDGET_SCOPE_TYPES.costCenterScoped;
}
if (hasDepartment && !hasLegalEntity && !hasCostCenter) {
return BUDGET_SCOPE_TYPES.departmentLegacy;
}
if (!hasDepartment && !hasLegalEntity && !hasCostCenter) {
return BUDGET_SCOPE_TYPES.global;
}
return "invalid";
}
function matchesBudgetPoolContext(record, context) {
const scopeType = resolveBudgetScopeType(record);
if (scopeType === BUDGET_SCOPE_TYPES.costCenterScoped) {
return (
Boolean(context.legalEntityId) &&
Boolean(context.costCenterId) &&
record.legal_entity_id === context.legalEntityId &&
record.cost_center_id === context.costCenterId
);
}
if (scopeType === BUDGET_SCOPE_TYPES.departmentLegacy) {
return Boolean(context.departmentId) && record.department_id === context.departmentId;
}
if (scopeType === BUDGET_SCOPE_TYPES.global) {
return true;
}
return false;
}
function normalizeBudgetContext({ departmentId = null, legalEntityId = null, costCenterId = null } = {}) {
if (costCenterId && !legalEntityId) {
throw buildValidationError({ costCenterId: "legalEntityId_required" });
}
return {
departmentId,
legalEntityId,
costCenterId,
};
}
function selectBudgetPoolCandidate(records, context = {}) {
const normalizedContext = normalizeBudgetContext(context);
for (const scopeType of BUDGET_SCOPE_PRIORITY) {
const matches = (records ?? []).filter(
(record) => resolveBudgetScopeType(record) === scopeType && matchesBudgetPoolContext(record, normalizedContext),
);
if (matches.length === 1) {
return matches[0];
}
if (matches.length > 1) {
if (scopeType === BUDGET_SCOPE_TYPES.costCenterScoped) {
throw buildValidationError({ budget: "budget_pool_ambiguous", scopeType }, 409);
}
return matches[0];
}
}
return null;
}
async function resolveApplicableBudgetPoolByContext(
tx,
{ departmentId = null, legalEntityId = null, costCenterId = null, effectiveDate },
) {
if (!(effectiveDate instanceof Date) || Number.isNaN(effectiveDate.getTime())) {
throw buildValidationError({ effectiveDate: "invalid_date" });
}
const records = await tx.budgetPool.findMany({
where: {
status: "active",
archived_at: null,
period_start: { lte: effectiveDate },
period_end: { gte: effectiveDate },
},
orderBy: [{ created_at: "asc" }, { code: "asc" }],
});
return selectBudgetPoolCandidate(records, {
departmentId,
legalEntityId,
costCenterId,
});
}
module.exports = {
BUDGET_SCOPE_TYPES,
resolveBudgetScope,
resolveBudgetScopeType,
selectBudgetPoolCandidate,
resolveApplicableBudgetPoolByContext,
};
+341
查看文件
@@ -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,
};