OAFLOW1 支持负责人本人报销兜底审批
这个提交包含在:
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "approval_rules"
|
||||
ADD COLUMN "owner_self_fallback_role" "RoleCode";
|
||||
|
||||
UPDATE "approval_rules"
|
||||
SET "owner_self_fallback_role" = 'finance_mgr'
|
||||
WHERE "enabled" = true
|
||||
AND "owner_self_fallback_role" IS NULL;
|
||||
@@ -3510,6 +3510,7 @@ model ApprovalRule {
|
||||
level1_role RoleCode
|
||||
level2_role RoleCode?
|
||||
level3_role RoleCode?
|
||||
owner_self_fallback_role RoleCode?
|
||||
enabled Boolean @default(true)
|
||||
priority Int
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const { ROLE_PERMISSIONS } = require("../constants/permissions");
|
||||
|
||||
const ROLE_CODES = Object.keys(ROLE_PERMISSIONS);
|
||||
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/);
|
||||
const OWNER_SELF_FALLBACK_ROLE_CODES = ["finance_mgr"];
|
||||
|
||||
const CREATE_BODY_SCHEMA = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
@@ -15,6 +16,7 @@ const CREATE_BODY_SCHEMA = z.object({
|
||||
level1Role: z.enum(ROLE_CODES),
|
||||
level2Role: z.enum(ROLE_CODES).nullable().optional(),
|
||||
level3Role: z.enum(ROLE_CODES).nullable().optional(),
|
||||
ownerSelfFallbackRole: z.enum(OWNER_SELF_FALLBACK_ROLE_CODES).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
priority: z.number().int().min(0).max(10000).optional(),
|
||||
});
|
||||
@@ -75,6 +77,21 @@ function ensureAmountRangeValid(minAmount, maxAmount) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOwnerSelfFallbackRoleValid(roleCode) {
|
||||
if (roleCode == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OWNER_SELF_FALLBACK_ROLE_CODES.includes(roleCode)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { ownerSelfFallbackRole: "unsupported_business_approval_role" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapApprovalRule(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -87,6 +104,7 @@ function mapApprovalRule(item) {
|
||||
level1Role: item.level1_role,
|
||||
level2Role: item.level2_role ?? null,
|
||||
level3Role: item.level3_role ?? null,
|
||||
ownerSelfFallbackRole: item.owner_self_fallback_role ?? null,
|
||||
enabled: item.enabled,
|
||||
priority: item.priority,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
@@ -104,6 +122,7 @@ async function approvalRulesRoutes(fastify) {
|
||||
const body = parseBody(CREATE_BODY_SCHEMA, request.body);
|
||||
const maxAmount = Object.prototype.hasOwnProperty.call(body, "maxAmount") ? body.maxAmount : null;
|
||||
ensureAmountRangeValid(body.minAmount, maxAmount);
|
||||
ensureOwnerSelfFallbackRoleValid(body.ownerSelfFallbackRole);
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
let priority = body.priority;
|
||||
@@ -120,6 +139,9 @@ async function approvalRulesRoutes(fastify) {
|
||||
level1_role: body.level1Role,
|
||||
level2_role: body.level2Role ?? null,
|
||||
level3_role: body.level3Role ?? null,
|
||||
owner_self_fallback_role: Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole")
|
||||
? body.ownerSelfFallbackRole ?? null
|
||||
: "finance_mgr",
|
||||
enabled: body.enabled ?? true,
|
||||
priority,
|
||||
created_by: request.employee.id,
|
||||
@@ -133,6 +155,7 @@ async function approvalRulesRoutes(fastify) {
|
||||
level1_role: true,
|
||||
level2_role: true,
|
||||
level3_role: true,
|
||||
owner_self_fallback_role: true,
|
||||
enabled: true,
|
||||
priority: true,
|
||||
created_at: true,
|
||||
@@ -156,6 +179,7 @@ async function approvalRulesRoutes(fastify) {
|
||||
level1Role: created.level1_role,
|
||||
level2Role: created.level2_role ?? null,
|
||||
level3Role: created.level3_role ?? null,
|
||||
ownerSelfFallbackRole: created.owner_self_fallback_role ?? null,
|
||||
enabled: created.enabled,
|
||||
priority: created.priority,
|
||||
},
|
||||
@@ -186,6 +210,9 @@ async function approvalRulesRoutes(fastify) {
|
||||
details: { body: "empty_patch" },
|
||||
});
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole")) {
|
||||
ensureOwnerSelfFallbackRoleValid(body.ownerSelfFallbackRole);
|
||||
}
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const locked = await tx.$queryRaw`
|
||||
@@ -212,6 +239,7 @@ async function approvalRulesRoutes(fastify) {
|
||||
level1_role: true,
|
||||
level2_role: true,
|
||||
level3_role: true,
|
||||
owner_self_fallback_role: true,
|
||||
enabled: true,
|
||||
priority: true,
|
||||
created_at: true,
|
||||
@@ -251,6 +279,9 @@ async function approvalRulesRoutes(fastify) {
|
||||
...(Object.prototype.hasOwnProperty.call(body, "level3Role")
|
||||
? { level3_role: body.level3Role ?? null }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole")
|
||||
? { owner_self_fallback_role: body.ownerSelfFallbackRole ?? null }
|
||||
: {}),
|
||||
...(body.enabled != null ? { enabled: body.enabled } : {}),
|
||||
...(body.priority != null ? { priority: body.priority } : {}),
|
||||
updated_by: request.employee.id,
|
||||
@@ -263,6 +294,7 @@ async function approvalRulesRoutes(fastify) {
|
||||
level1_role: true,
|
||||
level2_role: true,
|
||||
level3_role: true,
|
||||
owner_self_fallback_role: true,
|
||||
enabled: true,
|
||||
priority: true,
|
||||
created_at: true,
|
||||
@@ -291,4 +323,3 @@ async function approvalRulesRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = approvalRulesRoutes;
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ function mapApprovalRule(item) {
|
||||
level1Role: item.level1_role,
|
||||
level2Role: item.level2_role ?? null,
|
||||
level3Role: item.level3_role ?? null,
|
||||
ownerSelfFallbackRole: item.owner_self_fallback_role ?? null,
|
||||
enabled: item.enabled,
|
||||
priority: item.priority,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
@@ -115,6 +116,7 @@ async function configReadRoutes(fastify) {
|
||||
level1_role: true,
|
||||
level2_role: true,
|
||||
level3_role: true,
|
||||
owner_self_fallback_role: true,
|
||||
enabled: true,
|
||||
priority: true,
|
||||
created_at: true,
|
||||
|
||||
@@ -32,7 +32,7 @@ const {
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
buildApprovalWorkflowSnapshot,
|
||||
getWorkflowStepsSnapshot,
|
||||
resolveActionableStep,
|
||||
toMoneyString,
|
||||
@@ -1024,6 +1024,7 @@ async function loadApprovalRuleForAmount(tx, totalAmount) {
|
||||
level1_role: true,
|
||||
level2_role: true,
|
||||
level3_role: true,
|
||||
owner_self_fallback_role: true,
|
||||
priority: true,
|
||||
},
|
||||
});
|
||||
@@ -3231,7 +3232,12 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const approvalRule = await loadApprovalRuleForAmount(tx, reimbursement.total_amount);
|
||||
const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
|
||||
const workflowSteps = await buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
approvalRule,
|
||||
ownerId: reimbursement.owner_id,
|
||||
logger: fastify.log,
|
||||
});
|
||||
const { actionable, skipped } = await resolveActionableStep({
|
||||
tx,
|
||||
steps: workflowSteps,
|
||||
|
||||
@@ -191,7 +191,12 @@ function buildRoleFindings(employees, pendingAssignedRows, pendingRoleRows, appr
|
||||
})),
|
||||
approvalRulesUseAdmin: approvalRuleRows.map((row) => ({
|
||||
ruleName: row.name,
|
||||
roles: [row.level1_role, row.level2_role, row.level3_role].filter(Boolean),
|
||||
roles: [
|
||||
row.level1_role,
|
||||
row.level2_role,
|
||||
row.level3_role,
|
||||
row.owner_self_fallback_role,
|
||||
].filter(Boolean),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -328,13 +333,14 @@ async function queryAll(client) {
|
||||
`);
|
||||
|
||||
const approvalRuleResult = await client.query(`
|
||||
SELECT name, level1_role::text, level2_role::text, level3_role::text
|
||||
SELECT name, level1_role::text, level2_role::text, level3_role::text, owner_self_fallback_role::text
|
||||
FROM approval_rules
|
||||
WHERE enabled = true
|
||||
AND (
|
||||
level1_role = 'admin'
|
||||
OR level2_role = 'admin'
|
||||
OR level3_role = 'admin'
|
||||
OR owner_self_fallback_role = 'admin'
|
||||
)
|
||||
ORDER BY priority ASC, name ASC
|
||||
`);
|
||||
|
||||
@@ -56,6 +56,7 @@ const DEFAULT_APPROVAL_RULES = [
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
@@ -65,6 +66,7 @@ const DEFAULT_APPROVAL_RULES = [
|
||||
level1_role: "manager",
|
||||
level2_role: "finance_mgr",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
priority: 20,
|
||||
},
|
||||
{
|
||||
@@ -74,6 +76,7 @@ const DEFAULT_APPROVAL_RULES = [
|
||||
level1_role: "manager",
|
||||
level2_role: "ceo",
|
||||
level3_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
priority: 30,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ const { AppError } = require("../utils/AppError");
|
||||
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
buildApprovalWorkflowSnapshot,
|
||||
parseWorkflowSteps,
|
||||
resolveActionableStep,
|
||||
} = require("../utils/workflow");
|
||||
@@ -590,20 +590,40 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger
|
||||
level1_role: true,
|
||||
level2_role: true,
|
||||
level3_role: true,
|
||||
owner_self_fallback_role: true,
|
||||
priority: true,
|
||||
},
|
||||
});
|
||||
if (!approvalRule) {
|
||||
blocking.push(buildRiskFlagPreview("missing_approval_rule", RISK_LEVELS.high, "缺少审批规则", "当前金额范围未匹配到有效报销审批规则。"));
|
||||
} else {
|
||||
const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
|
||||
const { actionable, skipped } = await resolveActionableStep({
|
||||
tx,
|
||||
steps: workflowSteps,
|
||||
startIndex: 0,
|
||||
ownerId: reimbursement.owner_id,
|
||||
logger,
|
||||
});
|
||||
let actionable = null;
|
||||
let skipped = [];
|
||||
try {
|
||||
const workflowSteps = await buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
approvalRule,
|
||||
ownerId: reimbursement.owner_id,
|
||||
logger,
|
||||
});
|
||||
const resolved = await resolveActionableStep({
|
||||
tx,
|
||||
steps: workflowSteps,
|
||||
startIndex: 0,
|
||||
ownerId: reimbursement.owner_id,
|
||||
logger,
|
||||
blockOnUnresolvedAssignee: true,
|
||||
});
|
||||
actionable = resolved.actionable;
|
||||
skipped = resolved.skipped;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.details?.workflow === "no_actionable_approver") {
|
||||
actionable = null;
|
||||
skipped = [{ reason: error.details.reason ?? "no_actionable_approver" }];
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!actionable || skipped.length) {
|
||||
blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前审批链未解析出有效审批人。"));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ const BLOCKING_ASSIGNEE_SKIP_REASONS = new Set([
|
||||
"cycle_detected",
|
||||
"max_depth_exceeded",
|
||||
]);
|
||||
const SUPPORTED_OWNER_SELF_FALLBACK_ROLES = new Set(["finance_mgr"]);
|
||||
const OWNER_SELF_FALLBACK_REASONS = new Set([
|
||||
"no_manager_found",
|
||||
"chain_broken",
|
||||
"self_loop",
|
||||
"cycle_detected",
|
||||
"max_depth_exceeded",
|
||||
]);
|
||||
|
||||
function parseWorkflowSteps(definitionJson) {
|
||||
const steps = definitionJson?.steps;
|
||||
@@ -60,6 +68,193 @@ function buildWorkflowStepsFromApprovalRule(approvalRule) {
|
||||
}));
|
||||
}
|
||||
|
||||
function isDepartmentManagerRule(rule) {
|
||||
return rule?.type === "department_manager";
|
||||
}
|
||||
|
||||
function getRoleCodeFromStep(step) {
|
||||
const rule = step?.assignee_rule;
|
||||
return rule?.type === "role" && typeof rule.value === "string" ? rule.value : null;
|
||||
}
|
||||
|
||||
function buildRoleApprovalStep(roleCode, index, sourceStep, reason) {
|
||||
const suffix = reason === "owner_self_fallback" ? "_owner_self_fallback" : "";
|
||||
return {
|
||||
id: `approval_${index + 1}_${roleCode}${suffix}`,
|
||||
name:
|
||||
reason === "owner_self_fallback"
|
||||
? `负责人本人申请兜底:${roleCode}`
|
||||
: sourceStep?.name ?? `审批节点${index + 1}`,
|
||||
type: sourceStep?.type ?? "approval",
|
||||
assignee_rule: { type: "role", value: roleCode },
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOwnerRoleCodes(tx, ownerId) {
|
||||
const owner = await tx.employee.findUnique({
|
||||
where: { id: ownerId },
|
||||
select: {
|
||||
id: true,
|
||||
roles: {
|
||||
select: {
|
||||
role_code: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!owner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Set(owner.roles.map((item) => item.role_code));
|
||||
}
|
||||
|
||||
async function hasActiveNonSelfRoleHolder(tx, roleCode, ownerId) {
|
||||
const holder = await tx.employeeRole.findFirst({
|
||||
where: {
|
||||
role_code: roleCode,
|
||||
employee_id: { not: ownerId },
|
||||
employee: {
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
employee_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Boolean(holder);
|
||||
}
|
||||
|
||||
function assertOwnerSelfFallbackRole(roleCode, ownerRoleCodes) {
|
||||
if (!roleCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_OWNER_SELF_FALLBACK_ROLES.has(roleCode)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { ownerSelfFallbackRole: "unsupported_business_approval_role" },
|
||||
});
|
||||
}
|
||||
|
||||
if (ownerRoleCodes?.has(roleCode)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
workflow: "no_actionable_approver",
|
||||
reason: "owner_self_fallback_self_role",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return roleCode;
|
||||
}
|
||||
|
||||
async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, logger }) {
|
||||
const rawSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
|
||||
const hasManagerStep = rawSteps.some((step) => isDepartmentManagerRule(step.assignee_rule));
|
||||
|
||||
if (!hasManagerStep || !approvalRule.owner_self_fallback_role) {
|
||||
return rawSteps;
|
||||
}
|
||||
|
||||
const ownerRoleCodes = await loadOwnerRoleCodes(tx, ownerId);
|
||||
|
||||
if (!ownerRoleCodes?.has("ceo")) {
|
||||
return rawSteps;
|
||||
}
|
||||
|
||||
const fallbackRole = assertOwnerSelfFallbackRole(
|
||||
approvalRule.owner_self_fallback_role,
|
||||
ownerRoleCodes,
|
||||
);
|
||||
const normalizedSteps = [];
|
||||
const seenRoleCodes = new Set();
|
||||
|
||||
for (const rawStep of rawSteps) {
|
||||
const roleCode = getRoleCodeFromStep(rawStep);
|
||||
if (roleCode) {
|
||||
if (roleCode === "ceo") {
|
||||
continue;
|
||||
}
|
||||
if (seenRoleCodes.has(roleCode)) {
|
||||
continue;
|
||||
}
|
||||
normalizedSteps.push(rawStep);
|
||||
seenRoleCodes.add(roleCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isDepartmentManagerRule(rawStep.assignee_rule)) {
|
||||
normalizedSteps.push(rawStep);
|
||||
continue;
|
||||
}
|
||||
|
||||
const managerResult = await resolveDepartmentManagerAssignee({
|
||||
tx,
|
||||
ownerId,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (managerResult.assigneeId) {
|
||||
normalizedSteps.push(rawStep);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fallbackRole || !OWNER_SELF_FALLBACK_REASONS.has(managerResult.reason)) {
|
||||
normalizedSteps.push(rawStep);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasHolder = await hasActiveNonSelfRoleHolder(tx, fallbackRole, ownerId);
|
||||
if (!hasHolder) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
workflow: "no_actionable_approver",
|
||||
reason: "owner_self_fallback_role_unavailable",
|
||||
fallbackRole,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!seenRoleCodes.has(fallbackRole)) {
|
||||
normalizedSteps.push(
|
||||
buildRoleApprovalStep(
|
||||
fallbackRole,
|
||||
normalizedSteps.length,
|
||||
rawStep,
|
||||
"owner_self_fallback",
|
||||
),
|
||||
);
|
||||
seenRoleCodes.add(fallbackRole);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSteps.length === 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
workflow: "no_actionable_approver",
|
||||
reason: "owner_self_fallback_empty_chain",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedSteps;
|
||||
}
|
||||
|
||||
async function resolveDepartmentManagerAssignee({ tx, ownerId, logger }) {
|
||||
let currentId = ownerId;
|
||||
const visited = new Set();
|
||||
@@ -322,6 +517,7 @@ function toMoneyString(value) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildApprovalWorkflowSnapshot,
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
getWorkflowStepsSnapshot,
|
||||
parseWorkflowSteps,
|
||||
|
||||
@@ -2,7 +2,7 @@ const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const featureFlagState = {
|
||||
const featureFlagState = vi.hoisted(() => ({
|
||||
portalHome: true,
|
||||
announcements: false,
|
||||
presenceBoard: false,
|
||||
@@ -13,7 +13,7 @@ const featureFlagState = {
|
||||
projectDomain: false,
|
||||
projectCommunicationLog: false,
|
||||
projectClosureChecklist: false,
|
||||
};
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/services/featureFlags", () => ({
|
||||
FEATURE_FLAG_KEYS: {
|
||||
@@ -224,13 +224,25 @@ async function buildApp(options = {}) {
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
if (where.flag_key !== "portal_home_v1") {
|
||||
const enabledByFlagKey = {
|
||||
portal_home_v1: featureFlagState.portalHome,
|
||||
announcements_v1: featureFlagState.announcements,
|
||||
presence_board_v1: featureFlagState.presenceBoard,
|
||||
weekly_report_v1: featureFlagState.weeklyReport,
|
||||
employee_lifecycle_v1: featureFlagState.employeeLifecycle,
|
||||
todo_board_v1: featureFlagState.todoBoard,
|
||||
payment_batch_v1: featureFlagState.paymentBatch,
|
||||
project_domain_v1: featureFlagState.projectDomain,
|
||||
project_communication_log_v1: featureFlagState.projectCommunicationLog,
|
||||
project_closure_checklist_v1: featureFlagState.projectClosureChecklist,
|
||||
};
|
||||
if (!Object.prototype.hasOwnProperty.call(enabledByFlagKey, where.flag_key)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: "flag-portal-home",
|
||||
flag_key: "portal_home_v1",
|
||||
override_mode: "force_on",
|
||||
id: `flag-${where.flag_key}`,
|
||||
flag_key: where.flag_key,
|
||||
override_mode: enabledByFlagKey[where.flag_key] ? "force_on" : "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
@@ -284,16 +296,16 @@ async function buildApp(options = {}) {
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
portalHomeEnabled: true,
|
||||
announcementsEnabled: false,
|
||||
presenceBoardEnabled: false,
|
||||
weeklyReportEnabled: false,
|
||||
employeeLifecycleEnabled: false,
|
||||
todoBoardEnabled: false,
|
||||
paymentBatchEnabled: false,
|
||||
projectDomainEnabled: false,
|
||||
projectCommunicationLogEnabled: false,
|
||||
projectClosureChecklistEnabled: false,
|
||||
portalHomeEnabled: featureFlagState.portalHome,
|
||||
announcementsEnabled: featureFlagState.announcements,
|
||||
presenceBoardEnabled: featureFlagState.presenceBoard,
|
||||
weeklyReportEnabled: featureFlagState.weeklyReport,
|
||||
employeeLifecycleEnabled: featureFlagState.employeeLifecycle,
|
||||
todoBoardEnabled: featureFlagState.todoBoard,
|
||||
paymentBatchEnabled: featureFlagState.paymentBatch,
|
||||
projectDomainEnabled: featureFlagState.projectDomain,
|
||||
projectCommunicationLogEnabled: featureFlagState.projectCommunicationLog,
|
||||
projectClosureChecklistEnabled: featureFlagState.projectClosureChecklist,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
@@ -509,6 +521,7 @@ describe("portal routes", () => {
|
||||
pendingBatchCount: 3,
|
||||
failedItemCount: 2,
|
||||
riskCount: 0,
|
||||
paymentBatchEnabled: true,
|
||||
href: "/ap-operations",
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const FINANCE_MGR_ID = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
function buildPrecheck(overrides = {}) {
|
||||
return {
|
||||
@@ -64,8 +65,22 @@ async function buildApp(options = {}) {
|
||||
form_number: "BX-20260408-001",
|
||||
total_amount: "180.00",
|
||||
};
|
||||
const approvalRule = options.approvalRule ?? {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
level1_role: "finance",
|
||||
level2_role: null,
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: null,
|
||||
priority: 1,
|
||||
};
|
||||
const ownerRoles = options.ownerRoles ?? ["employee"];
|
||||
const ownerManagerId = Object.prototype.hasOwnProperty.call(options, "ownerManagerId")
|
||||
? options.ownerManagerId
|
||||
: null;
|
||||
const fallbackHasHolder = options.fallbackHasHolder ?? true;
|
||||
const state = {
|
||||
workflowInstanceCreatePayload: null,
|
||||
workflowTaskCreatePayload: null,
|
||||
};
|
||||
|
||||
const tx = {
|
||||
@@ -91,13 +106,33 @@ async function buildApp(options = {}) {
|
||||
update: vi.fn(async () => ({ id: "budget-pool-1" })),
|
||||
},
|
||||
approvalRule: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
level1_role: "finance",
|
||||
level2_role: null,
|
||||
level3_role: null,
|
||||
priority: 1,
|
||||
})),
|
||||
findFirst: vi.fn(async () => approvalRule),
|
||||
},
|
||||
employee: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
if (where.id === reimbursement.owner_id) {
|
||||
return {
|
||||
id: reimbursement.owner_id,
|
||||
direct_manager_id: ownerManagerId,
|
||||
roles: ownerRoles.map((role_code) => ({ role_code })),
|
||||
};
|
||||
}
|
||||
|
||||
if (where.id === ownerManagerId) {
|
||||
return {
|
||||
id: ownerManagerId,
|
||||
direct_manager_id: null,
|
||||
roles: [{ role_code: "manager" }],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
employeeRole: {
|
||||
findFirst: vi.fn(async () => (
|
||||
fallbackHasHolder ? { employee_id: FINANCE_MGR_ID } : null
|
||||
)),
|
||||
},
|
||||
workflowDefinition: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
@@ -110,11 +145,14 @@ async function buildApp(options = {}) {
|
||||
})),
|
||||
},
|
||||
workflowTask: {
|
||||
create: vi.fn(async () => ({
|
||||
create: vi.fn(async ({ data }) => {
|
||||
state.workflowTaskCreatePayload = data;
|
||||
return {
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
assignee_id: null,
|
||||
assignee_role: "finance",
|
||||
})),
|
||||
assignee_id: data.assignee_id,
|
||||
assignee_role: data.assignee_role,
|
||||
};
|
||||
}),
|
||||
},
|
||||
workflowHistory: {
|
||||
create: vi.fn(async () => ({ id: "history-1" })),
|
||||
@@ -360,4 +398,80 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uses finance_mgr fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
|
||||
const { app, state } = await buildApp({
|
||||
ownerRoles: ["ceo"],
|
||||
ownerManagerId: null,
|
||||
approvalRule: {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: "ceo",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(state.workflowTaskCreatePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
assignee_id: null,
|
||||
assignee_role: "finance_mgr",
|
||||
}),
|
||||
);
|
||||
expect(state.workflowInstanceCreatePayload.current_step_id).toContain("owner_self_fallback");
|
||||
expect(
|
||||
state.workflowInstanceCreatePayload.context_data.workflow_steps_snapshot.map(
|
||||
(step) => step.assignee_rule,
|
||||
),
|
||||
).toEqual([
|
||||
{ type: "role", value: "finance_mgr" },
|
||||
{ type: "role", value: "finance" },
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("blocks CEO owner submit when owner fallback has no active non-self holder", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
|
||||
const { app, tx } = await buildApp({
|
||||
ownerRoles: ["ceo"],
|
||||
ownerManagerId: null,
|
||||
fallbackHasHolder: false,
|
||||
approvalRule: {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual(
|
||||
expect.objectContaining({
|
||||
workflow: "no_actionable_approver",
|
||||
reason: "owner_self_fallback_role_unavailable",
|
||||
fallbackRole: "finance_mgr",
|
||||
}),
|
||||
);
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ const workflowUtils = require("../../../src/utils/workflow");
|
||||
const { resolveActionableStep } = workflowUtils;
|
||||
|
||||
const OWNER_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const MANAGER_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const FINANCE_MGR_ID = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
function buildSteps() {
|
||||
return [
|
||||
@@ -21,6 +23,41 @@ function buildSteps() {
|
||||
];
|
||||
}
|
||||
|
||||
function buildTx({
|
||||
ownerRoles = ["ceo"],
|
||||
managerId = null,
|
||||
fallbackHasHolder = true,
|
||||
} = {}) {
|
||||
return {
|
||||
employee: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
if (where.id === OWNER_ID) {
|
||||
return {
|
||||
id: OWNER_ID,
|
||||
direct_manager_id: managerId,
|
||||
roles: ownerRoles.map((role_code) => ({ role_code })),
|
||||
};
|
||||
}
|
||||
|
||||
if (where.id === MANAGER_ID) {
|
||||
return {
|
||||
id: MANAGER_ID,
|
||||
direct_manager_id: null,
|
||||
roles: [{ role_code: "manager" }],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
employeeRole: {
|
||||
findFirst: vi.fn(async () => (
|
||||
fallbackHasHolder ? { employee_id: FINANCE_MGR_ID } : null
|
||||
)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workflow assignee resolution", () => {
|
||||
describe("parseWorkflowSteps", () => {
|
||||
it("throws error for invalid steps array", () => {
|
||||
@@ -51,6 +88,171 @@ describe("workflow assignee resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildApprovalWorkflowSnapshot", () => {
|
||||
it("keeps ordinary employee approval snapshots unchanged", async () => {
|
||||
const tx = buildTx({ ownerRoles: ["employee"], managerId: MANAGER_ID });
|
||||
|
||||
const result = await workflowUtils.buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.map((step) => step.assignee_rule)).toEqual([
|
||||
{ type: "department_manager" },
|
||||
{ type: "role", value: "finance" },
|
||||
]);
|
||||
expect(tx.employeeRole.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses finance_mgr as non-self fallback for CEO owner applications", async () => {
|
||||
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
|
||||
|
||||
const steps = await workflowUtils.buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
},
|
||||
});
|
||||
|
||||
expect(steps.map((step) => step.assignee_rule)).toEqual([
|
||||
{ type: "role", value: "finance_mgr" },
|
||||
{ type: "role", value: "finance" },
|
||||
]);
|
||||
expect(steps[0].id).toContain("owner_self_fallback");
|
||||
|
||||
const resolved = await resolveActionableStep({
|
||||
tx,
|
||||
steps,
|
||||
startIndex: 0,
|
||||
ownerId: OWNER_ID,
|
||||
blockOnUnresolvedAssignee: true,
|
||||
});
|
||||
expect(resolved.actionable.assigneeRole).toBe("finance_mgr");
|
||||
});
|
||||
|
||||
it("removes CEO self node and duplicate fallback role for large CEO owner applications", async () => {
|
||||
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
|
||||
|
||||
const steps = await workflowUtils.buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
level2_role: "ceo",
|
||||
level3_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
},
|
||||
});
|
||||
|
||||
expect(steps.map((step) => step.assignee_rule)).toEqual([
|
||||
{ type: "role", value: "finance_mgr" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps missing-manager blocking behavior when no owner fallback is configured", async () => {
|
||||
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
|
||||
|
||||
const steps = await workflowUtils.buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
owner_self_fallback_role: null,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveActionableStep({
|
||||
tx,
|
||||
steps,
|
||||
startIndex: 0,
|
||||
ownerId: OWNER_ID,
|
||||
blockOnUnresolvedAssignee: true,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
details: {
|
||||
workflow: "no_actionable_approver",
|
||||
reason: "no_manager_found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks fallback when no active non-self holder exists", async () => {
|
||||
const tx = buildTx({
|
||||
ownerRoles: ["ceo"],
|
||||
managerId: null,
|
||||
fallbackHasHolder: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
workflowUtils.buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
details: {
|
||||
workflow: "no_actionable_approver",
|
||||
reason: "owner_self_fallback_role_unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks fallback when the owner also holds the fallback role", async () => {
|
||||
const tx = buildTx({
|
||||
ownerRoles: ["ceo", "finance_mgr"],
|
||||
managerId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
workflowUtils.buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
details: {
|
||||
workflow: "no_actionable_approver",
|
||||
reason: "owner_self_fallback_self_role",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks unsupported owner self fallback roles before creating a task", async () => {
|
||||
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
|
||||
|
||||
await expect(
|
||||
workflowUtils.buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
owner_self_fallback_role: "hr",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
details: {
|
||||
ownerSelfFallbackRole: "unsupported_business_approval_role",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAssigneeFromRule", () => {
|
||||
it("resolves role type", async () => {
|
||||
const result = await workflowUtils.resolveAssigneeFromRule({ type: "role", value: "hr" });
|
||||
|
||||
在新工单中引用
屏蔽一个用户