feat(G2-1,G2-2,G2-3): 扩展预算编号与工作流跨域桥接
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户