333 行
7.7 KiB
JavaScript
333 行
7.7 KiB
JavaScript
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;
|
|
const BLOCKING_ASSIGNEE_SKIP_REASONS = new Set([
|
|
"no_manager_found",
|
|
"chain_broken",
|
|
"self_loop",
|
|
"cycle_detected",
|
|
"max_depth_exceeded",
|
|
]);
|
|
|
|
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,
|
|
blockOnUnresolvedAssignee = false,
|
|
}) {
|
|
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) {
|
|
if (blockOnUnresolvedAssignee && BLOCKING_ASSIGNEE_SKIP_REASONS.has(resolved.skipReason)) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: {
|
|
workflow: "no_actionable_approver",
|
|
reason: resolved.skipReason,
|
|
stepId: step.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|