WRULE1 实现企微审批链校验器 POC
这个提交包含在:
@@ -0,0 +1,331 @@
|
||||
const { maskApprovalIdentifier } = require("./wecomApproval");
|
||||
|
||||
const APPROVAL_RULE_TARGETS = Object.freeze(["leave", "missed_punch", "outing", "overtime"]);
|
||||
const MANUAL_GATE_VALUES = Object.freeze(["A", "B", "C", "D"]);
|
||||
|
||||
function toSafeString(value, maxLength = 500) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function roleCodes(employee) {
|
||||
return toArray(employee?.roles)
|
||||
.map((item) => (typeof item === "string" ? item : item?.role_code))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasWecomBinding(employee) {
|
||||
return Boolean(employee?.wecom_identity_binding?.external_user_id);
|
||||
}
|
||||
|
||||
function isActiveEmployee(employee) {
|
||||
return Boolean(employee && employee.status === "active");
|
||||
}
|
||||
|
||||
function employeeSafeSummary(employee) {
|
||||
if (!employee) return null;
|
||||
return {
|
||||
employeeNoPrefix: maskEmployeeIdentifier(employee.employee_no),
|
||||
status: employee.status || "unknown",
|
||||
roleCodes: roleCodes(employee),
|
||||
hasWecomBinding: hasWecomBinding(employee),
|
||||
};
|
||||
}
|
||||
|
||||
function maskEmployeeIdentifier(value) {
|
||||
const raw = toSafeString(value, 80);
|
||||
if (!raw) return null;
|
||||
if (raw.length <= 4) return `${raw.slice(0, 1)}***`;
|
||||
return `${raw.slice(0, 3)}***${raw.slice(-2)}`;
|
||||
}
|
||||
|
||||
function uniqueById(items) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const item of items || []) {
|
||||
if (!item?.id || seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeManualGates(rawManualGates = {}) {
|
||||
const gates = {};
|
||||
for (const [target, value] of Object.entries(rawManualGates || {})) {
|
||||
const normalizedTarget = toSafeString(target, 40);
|
||||
const gate = toSafeString(value, 4).toUpperCase();
|
||||
if (!APPROVAL_RULE_TARGETS.includes(normalizedTarget) || !MANUAL_GATE_VALUES.includes(gate)) continue;
|
||||
gates[normalizedTarget] = gate;
|
||||
}
|
||||
return gates;
|
||||
}
|
||||
|
||||
function buildExpectedApprovers({ applicant, fallbackEmployee, approvalRoleEmployees = [] }) {
|
||||
const findings = [];
|
||||
const directManager = applicant?.direct_manager || null;
|
||||
if (directManager && directManager.id === applicant?.id) {
|
||||
findings.push({
|
||||
type: "FAIL_SELF_APPROVAL",
|
||||
severity: "blocker",
|
||||
reason: "direct_manager_resolves_to_applicant",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
directManager
|
||||
&& directManager.id !== applicant?.id
|
||||
&& isActiveEmployee(directManager)
|
||||
&& hasWecomBinding(directManager)
|
||||
) {
|
||||
return {
|
||||
mode: "direct_manager",
|
||||
expectedEmployees: [directManager],
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
const validRoleEmployees = uniqueById(approvalRoleEmployees.filter((employee) => (
|
||||
employee.id !== applicant?.id
|
||||
&& isActiveEmployee(employee)
|
||||
&& hasWecomBinding(employee)
|
||||
)));
|
||||
if (validRoleEmployees.length) {
|
||||
return {
|
||||
mode: "approval_role",
|
||||
expectedEmployees: validRoleEmployees,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
fallbackEmployee
|
||||
&& fallbackEmployee.id !== applicant?.id
|
||||
&& isActiveEmployee(fallbackEmployee)
|
||||
&& hasWecomBinding(fallbackEmployee)
|
||||
) {
|
||||
return {
|
||||
mode: "admin_fallback",
|
||||
expectedEmployees: [fallbackEmployee],
|
||||
findings: [
|
||||
...findings,
|
||||
{
|
||||
type: "PASS_WITH_ADMIN_FALLBACK",
|
||||
severity: "warning",
|
||||
reason: "direct_manager_or_role_approver_missing_or_unavailable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "no_candidate",
|
||||
expectedEmployees: [],
|
||||
findings: [
|
||||
...findings,
|
||||
{
|
||||
type: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
severity: "blocker",
|
||||
reason: "no_valid_direct_manager_role_or_admin_fallback",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function actualApproverEmployees(archive) {
|
||||
const fromParticipants = toArray(archive?.participants)
|
||||
.filter((item) => item?.role === "approver" && item.employee)
|
||||
.sort((a, b) => (a.node_index || 0) - (b.node_index || 0))
|
||||
.map((item) => item.employee);
|
||||
if (fromParticipants.length) return uniqueById(fromParticipants);
|
||||
|
||||
const fromActions = toArray(archive?.actions)
|
||||
.filter((item) => item?.operator && ["approve", "reject", "pending"].includes(item.action_type))
|
||||
.sort((a, b) => (a.node_index || 0) - (b.node_index || 0))
|
||||
.map((item) => item.operator);
|
||||
return uniqueById(fromActions);
|
||||
}
|
||||
|
||||
function manualGateFinding(templateType, gate) {
|
||||
if (!gate) {
|
||||
return {
|
||||
type: "PAUSED_MANUAL_CONFIRMATION",
|
||||
severity: "warning",
|
||||
reason: "manual_wappr1_gate_missing",
|
||||
templateType,
|
||||
};
|
||||
}
|
||||
if (gate === "A") return null;
|
||||
if (gate === "D") {
|
||||
return {
|
||||
type: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
severity: "blocker",
|
||||
reason: "manual_wappr1_template_not_visible_or_not_configured",
|
||||
templateType,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "FAIL_APPLICANT_CAN_CHOOSE",
|
||||
severity: "blocker",
|
||||
reason: gate === "B" ? "manual_wappr1_approver_can_be_changed" : "manual_wappr1_approver_must_be_chosen_by_applicant",
|
||||
templateType,
|
||||
};
|
||||
}
|
||||
|
||||
function validateApprovalRuleArchive(archive, context = {}) {
|
||||
const templateType = archive?.template_type || "unknown";
|
||||
const manualGates = normalizeManualGates(context.manualGates);
|
||||
const applicant = archive?.applicant || null;
|
||||
const findings = [];
|
||||
const manualFinding = manualGateFinding(templateType, manualGates[templateType]);
|
||||
if (manualFinding) findings.push(manualFinding);
|
||||
|
||||
if (!applicant) {
|
||||
findings.push({
|
||||
type: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
severity: "blocker",
|
||||
reason: "applicant_not_mapped_to_oa_employee",
|
||||
templateType,
|
||||
});
|
||||
}
|
||||
|
||||
const expected = applicant
|
||||
? buildExpectedApprovers({
|
||||
applicant,
|
||||
fallbackEmployee: context.fallbackEmployee || null,
|
||||
approvalRoleEmployees: context.approvalRoleEmployees || [],
|
||||
})
|
||||
: { mode: "no_applicant", expectedEmployees: [], findings: [] };
|
||||
findings.push(...expected.findings);
|
||||
|
||||
const actualEmployees = actualApproverEmployees(archive);
|
||||
if (!actualEmployees.length) {
|
||||
findings.push({
|
||||
type: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||
severity: "blocker",
|
||||
reason: "actual_approver_missing_or_unmapped",
|
||||
templateType,
|
||||
});
|
||||
}
|
||||
|
||||
if (applicant && actualEmployees.some((employee) => employee.id === applicant.id)) {
|
||||
findings.push({
|
||||
type: "FAIL_SELF_APPROVAL",
|
||||
severity: "blocker",
|
||||
reason: "actual_approver_is_applicant",
|
||||
templateType,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedIds = new Set(expected.expectedEmployees.map((employee) => employee.id));
|
||||
const actualIds = new Set(actualEmployees.map((employee) => employee.id));
|
||||
const matched = [...actualIds].some((id) => expectedIds.has(id));
|
||||
if (actualEmployees.length && expected.expectedEmployees.length && !matched) {
|
||||
findings.push({
|
||||
type: "FAIL_APPROVER_MISMATCH",
|
||||
severity: "blocker",
|
||||
reason: "actual_approver_not_in_oa_theoretical_approvers",
|
||||
templateType,
|
||||
});
|
||||
}
|
||||
|
||||
const blockerTypes = new Set(findings.filter((item) => item.severity === "blocker").map((item) => item.type));
|
||||
let status = "PASS_RULE_MATCH";
|
||||
if (blockerTypes.has("FAIL_SELF_APPROVAL")) {
|
||||
status = "FAIL_SELF_APPROVAL";
|
||||
} else if (blockerTypes.has("FAIL_APPLICANT_CAN_CHOOSE")) {
|
||||
status = "FAIL_APPLICANT_CAN_CHOOSE";
|
||||
} else if (blockerTypes.has("FAIL_APPROVER_MISMATCH")) {
|
||||
status = "FAIL_APPROVER_MISMATCH";
|
||||
} else if (blockerTypes.has("SKIP/BLOCKED_BY_EXTERNAL_CONFIG")) {
|
||||
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
} else if (findings.some((item) => item.type === "PAUSED_MANUAL_CONFIRMATION")) {
|
||||
status = "PAUSED_MANUAL_CONFIRMATION";
|
||||
} else if (findings.some((item) => item.type === "PASS_WITH_ADMIN_FALLBACK")) {
|
||||
status = "PASS_WITH_ADMIN_FALLBACK";
|
||||
}
|
||||
|
||||
return {
|
||||
archiveId: archive?.id || null,
|
||||
spNoPrefix: maskApprovalIdentifier(archive?.sp_no),
|
||||
templateIdPrefix: maskApprovalIdentifier(archive?.template_id),
|
||||
templateType,
|
||||
normalizedStatus: archive?.normalized_status || "unknown",
|
||||
ruleMode: expected.mode,
|
||||
status,
|
||||
manualGate: manualGates[templateType] || null,
|
||||
applicant: employeeSafeSummary(applicant),
|
||||
expectedApprovers: expected.expectedEmployees.map(employeeSafeSummary),
|
||||
actualApprovers: actualEmployees.map(employeeSafeSummary),
|
||||
findings: findings.map((item) => ({
|
||||
type: item.type,
|
||||
severity: item.severity,
|
||||
reason: item.reason,
|
||||
templateType: item.templateType || templateType,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function countBy(items, selector) {
|
||||
const counts = {};
|
||||
for (const item of items || []) {
|
||||
const key = toSafeString(selector(item), 100) || "unknown";
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function evaluateRuleReport(items, {
|
||||
readError = null,
|
||||
manualGateFindings = [],
|
||||
manualGateGaps = [],
|
||||
templateDataGaps = [],
|
||||
noData = false,
|
||||
} = {}) {
|
||||
if (readError) return "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
if (items.some((item) => item.status === "FAIL_SELF_APPROVAL")) return "FAIL_SELF_APPROVAL";
|
||||
if (
|
||||
items.some((item) => item.status === "FAIL_APPLICANT_CAN_CHOOSE")
|
||||
|| manualGateFindings.some((item) => item.status === "FAIL_APPLICANT_CAN_CHOOSE")
|
||||
) {
|
||||
return "FAIL_APPLICANT_CAN_CHOOSE";
|
||||
}
|
||||
if (items.some((item) => item.status === "FAIL_APPROVER_MISMATCH")) return "FAIL_APPROVER_MISMATCH";
|
||||
if (
|
||||
items.some((item) => item.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG")
|
||||
|| manualGateFindings.some((item) => item.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG")
|
||||
) {
|
||||
return "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
}
|
||||
if (
|
||||
manualGateGaps.length
|
||||
|| items.some((item) => item.status === "PAUSED_MANUAL_CONFIRMATION")
|
||||
|| templateDataGaps.some((item) => item.status === "PAUSED_MANUAL_CONFIRMATION")
|
||||
) {
|
||||
return "PAUSED_MANUAL_CONFIRMATION";
|
||||
}
|
||||
if (templateDataGaps.some((item) => item.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG")) {
|
||||
return "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
}
|
||||
if (noData) return "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
if (items.some((item) => item.status === "PASS_WITH_ADMIN_FALLBACK")) return "PASS_WITH_ADMIN_FALLBACK";
|
||||
return "PASS_RULE_MATCH";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APPROVAL_RULE_TARGETS,
|
||||
MANUAL_GATE_VALUES,
|
||||
actualApproverEmployees,
|
||||
buildExpectedApprovers,
|
||||
countBy,
|
||||
employeeSafeSummary,
|
||||
evaluateRuleReport,
|
||||
maskEmployeeIdentifier,
|
||||
normalizeManualGates,
|
||||
validateApprovalRuleArchive,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户