feat(server): 完成 P0+P1 后端能力(工作流、RBAC、付款归档)
P0 MVP: - 认证:login/refresh/logout/forgot/reset + tokenVersion - RBAC:角色到权限映射 + requirePermission - 发票池:upload/bind/signed-url + 判重(VALID_003/VALID_005) - 报销:create/view/submit + 单号生成 - 工作流:approve + department_manager 解析 P1 扩展: - 动作:reject/return/withdraw(含 comment) - transfer:任务转审 + 链路上限 3 - pay:approved -> paid + 发票归档 used_complete - 动态审批链:approval_rules 驱动 回归脚本: - smoke-test.sh / regression-dedup.sh / regression-workflow-actions.sh
这个提交包含在:
@@ -0,0 +1,305 @@
|
||||
const { AppError } = require("./AppError");
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const MAX_MANAGER_DEPTH = 10;
|
||||
|
||||
function parseWorkflowSteps(definitionJson) {
|
||||
const steps = definitionJson?.steps;
|
||||
if (!Array.isArray(steps) || steps.length === 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "invalid_steps" },
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function buildWorkflowStepsFromApprovalRule(approvalRule) {
|
||||
if (!approvalRule || typeof approvalRule !== "object") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "invalid_approval_rule" },
|
||||
});
|
||||
}
|
||||
|
||||
const roleCodes = [
|
||||
approvalRule.level1_role,
|
||||
approvalRule.level2_role,
|
||||
approvalRule.level3_role,
|
||||
].filter(Boolean);
|
||||
|
||||
if (roleCodes.length === 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "empty_approval_chain" },
|
||||
});
|
||||
}
|
||||
|
||||
return roleCodes.map((roleCode, index) => ({
|
||||
id: `approval_${index + 1}_${roleCode}`,
|
||||
name: `审批节点${index + 1}`,
|
||||
type: "approval",
|
||||
assignee_rule:
|
||||
roleCode === "manager"
|
||||
? { type: "department_manager" }
|
||||
: { type: "role", value: roleCode },
|
||||
}));
|
||||
}
|
||||
|
||||
async function resolveDepartmentManagerAssignee({ tx, ownerId, logger }) {
|
||||
let currentId = ownerId;
|
||||
const visited = new Set();
|
||||
|
||||
for (let depth = 1; depth <= MAX_MANAGER_DEPTH; depth += 1) {
|
||||
if (visited.has(currentId)) {
|
||||
logger?.warn?.(
|
||||
{ ownerId, currentId, reason: "cycle_detected" },
|
||||
"Circular manager reference detected",
|
||||
);
|
||||
return {
|
||||
assigneeId: null,
|
||||
reason: "cycle_detected",
|
||||
depthChecked: depth - 1,
|
||||
};
|
||||
}
|
||||
visited.add(currentId);
|
||||
|
||||
const employee = await tx.employee.findUnique({
|
||||
where: { id: currentId },
|
||||
select: {
|
||||
id: true,
|
||||
direct_manager_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
return {
|
||||
assigneeId: null,
|
||||
reason: "chain_broken",
|
||||
depthChecked: depth,
|
||||
};
|
||||
}
|
||||
|
||||
const managerId = employee.direct_manager_id;
|
||||
if (!managerId) {
|
||||
return {
|
||||
assigneeId: null,
|
||||
reason: "no_manager_found",
|
||||
depthChecked: depth,
|
||||
};
|
||||
}
|
||||
|
||||
if (managerId === currentId) {
|
||||
logger?.warn?.(
|
||||
{ ownerId, currentId, reason: "self_loop" },
|
||||
"Self-loop manager reference detected",
|
||||
);
|
||||
return {
|
||||
assigneeId: null,
|
||||
reason: "self_loop",
|
||||
depthChecked: depth,
|
||||
};
|
||||
}
|
||||
|
||||
if (managerId === ownerId) {
|
||||
currentId = managerId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (visited.has(managerId)) {
|
||||
logger?.warn?.(
|
||||
{ ownerId, currentId: managerId, reason: "cycle_detected" },
|
||||
"Circular manager reference detected",
|
||||
);
|
||||
return {
|
||||
assigneeId: null,
|
||||
reason: "cycle_detected",
|
||||
depthChecked: depth,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
assigneeId: managerId,
|
||||
reason: null,
|
||||
depthChecked: depth,
|
||||
};
|
||||
}
|
||||
|
||||
logger?.warn?.(
|
||||
{ ownerId, currentId, reason: "max_depth_exceeded" },
|
||||
"Manager resolution reached max depth",
|
||||
);
|
||||
return {
|
||||
assigneeId: null,
|
||||
reason: "max_depth_exceeded",
|
||||
depthChecked: MAX_MANAGER_DEPTH,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAssigneeFromRule(rule, options = {}) {
|
||||
if (!rule || typeof rule !== "object") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "invalid_assignee_rule" },
|
||||
});
|
||||
}
|
||||
|
||||
if (rule.type === "role") {
|
||||
if (typeof rule.value !== "string" || !rule.value) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "invalid_role_assignee" },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
assigneeId: null,
|
||||
assigneeRole: rule.value,
|
||||
skipReason: null,
|
||||
depthChecked: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (rule.type === "specific_user") {
|
||||
if (typeof rule.value !== "string" || !UUID_REGEX.test(rule.value)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "invalid_specific_user_assignee" },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
assigneeId: rule.value,
|
||||
assigneeRole: null,
|
||||
skipReason: null,
|
||||
depthChecked: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (rule.type === "department_manager") {
|
||||
const ownerId = options.ownerId;
|
||||
const tx = options.tx;
|
||||
|
||||
if (!ownerId || !UUID_REGEX.test(ownerId) || !tx) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "invalid_department_manager_context" },
|
||||
});
|
||||
}
|
||||
|
||||
const managerResult = await resolveDepartmentManagerAssignee({
|
||||
tx,
|
||||
ownerId,
|
||||
logger: options.logger,
|
||||
});
|
||||
|
||||
if (!managerResult.assigneeId) {
|
||||
return {
|
||||
assigneeId: null,
|
||||
assigneeRole: null,
|
||||
skipReason: managerResult.reason,
|
||||
depthChecked: managerResult.depthChecked,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
assigneeId: managerResult.assigneeId,
|
||||
assigneeRole: null,
|
||||
skipReason: null,
|
||||
depthChecked: managerResult.depthChecked,
|
||||
};
|
||||
}
|
||||
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "unsupported_assignee_rule_type" },
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveActionableStep({ tx, steps, startIndex, ownerId, logger }) {
|
||||
const skipped = [];
|
||||
|
||||
for (let index = startIndex; index < steps.length; index += 1) {
|
||||
const step = steps[index];
|
||||
const resolved = await resolveAssigneeFromRule(step.assignee_rule, {
|
||||
tx,
|
||||
ownerId,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (resolved.assigneeId || resolved.assigneeRole) {
|
||||
return {
|
||||
actionable: {
|
||||
index,
|
||||
step,
|
||||
assigneeId: resolved.assigneeId,
|
||||
assigneeRole: resolved.assigneeRole,
|
||||
},
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
if (resolved.skipReason) {
|
||||
skipped.push({
|
||||
index,
|
||||
step,
|
||||
reason: resolved.skipReason,
|
||||
depthChecked: resolved.depthChecked,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { workflow: "invalid_resolved_assignee" },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
actionable: null,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkflowStepsSnapshot(workflowInstance) {
|
||||
const snapshot = workflowInstance?.context_data?.workflow_steps_snapshot;
|
||||
if (Array.isArray(snapshot) && snapshot.length > 0) {
|
||||
return parseWorkflowSteps({ steps: snapshot });
|
||||
}
|
||||
|
||||
return parseWorkflowSteps(workflowInstance?.workflow_definition?.definition_json);
|
||||
}
|
||||
|
||||
function toMoneyString(value) {
|
||||
if (value == null) return null;
|
||||
return typeof value === "string" ? value : value.toString();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
getWorkflowStepsSnapshot,
|
||||
parseWorkflowSteps,
|
||||
resolveActionableStep,
|
||||
resolveAssigneeFromRule,
|
||||
resolveDepartmentManagerAssignee,
|
||||
toMoneyString,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户