修正CEO报销财务兜底与部署护栏(07-28-ceo-finance-fallback)
这个提交包含在:
@@ -14,7 +14,7 @@ function buildCreatePayload(roleCode) {
|
||||
level1Role: roleCode,
|
||||
level2Role: null,
|
||||
level3Role: null,
|
||||
ownerSelfFallbackRole: "finance_mgr",
|
||||
ownerSelfFallbackRole: "finance",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
};
|
||||
@@ -82,6 +82,74 @@ async function buildApp() {
|
||||
}
|
||||
|
||||
describe("reimbursement approval-rule role whitelist", () => {
|
||||
it.each(["finance", "finance_mgr"])(
|
||||
"allows owner self fallback role %s",
|
||||
async (fallbackRole) => {
|
||||
const testApp = await buildApp();
|
||||
try {
|
||||
const response = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/approval-rules",
|
||||
payload: {
|
||||
...buildCreatePayload("manager"),
|
||||
ownerSelfFallbackRole: fallbackRole,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.ownerSelfFallbackRole).toBe(fallbackRole);
|
||||
} finally {
|
||||
await testApp.app.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("defaults owner self fallback to finance when omitted", async () => {
|
||||
const testApp = await buildApp();
|
||||
try {
|
||||
const payload = buildCreatePayload("manager");
|
||||
delete payload.ownerSelfFallbackRole;
|
||||
const response = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/approval-rules",
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.ownerSelfFallbackRole).toBe("finance");
|
||||
expect(testApp.approvalRuleCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ owner_self_fallback_role: "finance" }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await testApp.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["admin", "system_admin", "hr"])(
|
||||
"rejects owner self fallback role %s",
|
||||
async (fallbackRole) => {
|
||||
const testApp = await buildApp();
|
||||
try {
|
||||
const response = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/approval-rules",
|
||||
payload: {
|
||||
...buildCreatePayload("manager"),
|
||||
ownerSelfFallbackRole: fallbackRole,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.code).toBe("VALID_001");
|
||||
expect(testApp.transaction).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await testApp.app.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["manager", "ceo", "finance", "finance_mgr"])(
|
||||
"allows role %s because it carries expense.approve",
|
||||
async (roleCode) => {
|
||||
|
||||
@@ -399,7 +399,7 @@ 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 () => {
|
||||
it("uses one finance fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
|
||||
const { app, state } = await buildApp({
|
||||
@@ -409,8 +409,8 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: "ceo",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance",
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
@@ -424,7 +424,7 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
expect(state.workflowTaskCreatePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
assignee_id: null,
|
||||
assignee_role: "finance_mgr",
|
||||
assignee_role: "finance",
|
||||
}),
|
||||
);
|
||||
expect(state.workflowInstanceCreatePayload.current_step_id).toContain("owner_self_fallback");
|
||||
@@ -432,10 +432,7 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
state.workflowInstanceCreatePayload.context_data.workflow_steps_snapshot.map(
|
||||
(step) => step.assignee_rule,
|
||||
),
|
||||
).toEqual([
|
||||
{ type: "role", value: "finance_mgr" },
|
||||
{ type: "role", value: "finance" },
|
||||
]);
|
||||
).toEqual([{ type: "role", value: "finance" }]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -452,7 +449,7 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance",
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
@@ -467,7 +464,7 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
expect.objectContaining({
|
||||
workflow: "no_actionable_approver",
|
||||
reason: "owner_self_fallback_role_unavailable",
|
||||
fallbackRole: "finance_mgr",
|
||||
fallbackRole: "finance",
|
||||
}),
|
||||
);
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
const {
|
||||
assertPeopleBaseline,
|
||||
buildRulePlan,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/apply-ceo-finance-fallback");
|
||||
|
||||
function currentRows() {
|
||||
return [
|
||||
{
|
||||
id: "small-id",
|
||||
min_amount: "0.00",
|
||||
max_amount: "500.00",
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
},
|
||||
{
|
||||
id: "medium-id",
|
||||
min_amount: "500.00",
|
||||
max_amount: "5000.00",
|
||||
level1_role: "manager",
|
||||
level2_role: "finance_mgr",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
},
|
||||
{
|
||||
id: "large-id",
|
||||
min_amount: "5000.00",
|
||||
max_amount: null,
|
||||
level1_role: "manager",
|
||||
level2_role: "ceo",
|
||||
level3_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("CEO finance fallback production apply contract", () => {
|
||||
it("builds the exact three-band target without exposing identifiers in the result shape", () => {
|
||||
const plan = buildRulePlan(currentRows());
|
||||
expect(plan.map((item) => ({ band: item.band, after: item.after }))).toEqual([
|
||||
{
|
||||
band: "small",
|
||||
after: {
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance",
|
||||
},
|
||||
},
|
||||
{
|
||||
band: "medium",
|
||||
after: {
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance",
|
||||
},
|
||||
},
|
||||
{
|
||||
band: "large",
|
||||
after: {
|
||||
level1_role: "manager",
|
||||
level2_role: "ceo",
|
||||
level3_role: "finance",
|
||||
owner_self_fallback_role: "finance",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[currentRows().slice(0, 2), "approval_rule_count_drift"],
|
||||
[[...currentRows(), currentRows()[0]], "approval_rule_count_drift"],
|
||||
[currentRows().map((row, index) => index === 1 ? { ...row, level2_role: "finance" } : row), "approval_rule_role_drift:medium"],
|
||||
[currentRows().map((row, index) => index === 2 ? { ...row, min_amount: "6000" } : row), "approval_rule_amount_band_drift"],
|
||||
])("fails closed on approval-rule drift", (rows, message) => {
|
||||
expect(() => buildRulePlan(rows)).toThrow(message);
|
||||
});
|
||||
|
||||
it("requires one bound CEO without manager/fallback role, one bound non-CEO finance, and one admin", () => {
|
||||
expect(() => assertPeopleBaseline({
|
||||
ceos: [{ direct_manager_id: null, has_wecom_binding: true, roles: ["ceo"] }],
|
||||
finances: [{ has_wecom_binding: true, roles: ["finance"] }],
|
||||
admins: [{ roles: ["admin"] }],
|
||||
})).not.toThrow();
|
||||
|
||||
expect(() => assertPeopleBaseline({ ceos: [], finances: [], admins: [] }))
|
||||
.toThrow("active_ceo_count_drift");
|
||||
expect(() => assertPeopleBaseline({
|
||||
ceos: [{ direct_manager_id: null, has_wecom_binding: true, roles: ["ceo", "finance"] }],
|
||||
finances: [{ has_wecom_binding: true, roles: ["finance"] }],
|
||||
admins: [{ roles: ["admin"] }],
|
||||
})).toThrow("ceo_holds_fallback_role");
|
||||
});
|
||||
|
||||
it("defaults to dry-run and rejects unknown arguments", () => {
|
||||
expect(parseArgs([])).toEqual({ apply: false, format: "text" });
|
||||
expect(parseArgs(["--apply", "--format=json"])).toEqual({ apply: true, format: "json" });
|
||||
expect(() => parseArgs(["--force"])).toThrow("Unsupported argument");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const {
|
||||
DEFAULT_APPROVAL_RULES,
|
||||
seedApprovalRules,
|
||||
} = require("../../../src/scripts/seed-config");
|
||||
|
||||
describe("small-team default reimbursement approval rules", () => {
|
||||
it("uses finance for every owner fallback and finance review node", () => {
|
||||
expect(DEFAULT_APPROVAL_RULES).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "小额",
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "中额",
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "大额",
|
||||
level1_role: "manager",
|
||||
level2_role: "ceo",
|
||||
level3_role: "finance",
|
||||
owner_self_fallback_role: "finance",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves existing production approval rules instead of replacing them", async () => {
|
||||
const approvalRule = {
|
||||
count: vi.fn().mockResolvedValue(3),
|
||||
createMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(seedApprovalRules({ approvalRule })).resolves.toEqual({
|
||||
created: 0,
|
||||
preserved: 3,
|
||||
});
|
||||
expect(approvalRule.createMany).not.toHaveBeenCalled();
|
||||
expect(approvalRule.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the three defaults only for an empty approval-rule table", async () => {
|
||||
const approvalRule = {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 3 }),
|
||||
};
|
||||
|
||||
await expect(seedApprovalRules({ approvalRule })).resolves.toEqual({
|
||||
created: 3,
|
||||
preserved: 0,
|
||||
});
|
||||
expect(approvalRule.createMany).toHaveBeenCalledWith({
|
||||
data: DEFAULT_APPROVAL_RULES.map((item) => ({
|
||||
...item,
|
||||
enabled: true,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -164,6 +164,28 @@ describe("workflow assignee resolution", () => {
|
||||
expect(resolved.actionable.assigneeRole).toBe("finance_mgr");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["small", { level1_role: "manager", level2_role: "finance", level3_role: null }],
|
||||
["medium", { level1_role: "manager", level2_role: "finance", level3_role: null }],
|
||||
["large", { level1_role: "manager", level2_role: "ceo", level3_role: "finance" }],
|
||||
])("uses one finance fallback node for CEO owner %s rules", async (_name, rule) => {
|
||||
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
|
||||
|
||||
const steps = await workflowUtils.buildApprovalWorkflowSnapshot({
|
||||
tx,
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
...rule,
|
||||
owner_self_fallback_role: "finance",
|
||||
},
|
||||
});
|
||||
|
||||
expect(steps.map((step) => step.assignee_rule)).toEqual([
|
||||
{ type: "role", value: "finance" },
|
||||
]);
|
||||
expect(steps[0].id).toContain("owner_self_fallback");
|
||||
});
|
||||
|
||||
it("removes CEO self node and duplicate fallback role for large CEO owner applications", async () => {
|
||||
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
|
||||
|
||||
@@ -225,7 +247,7 @@ describe("workflow assignee resolution", () => {
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
@@ -238,7 +260,7 @@ describe("workflow assignee resolution", () => {
|
||||
|
||||
it("blocks fallback when the owner also holds the fallback role", async () => {
|
||||
const tx = buildTx({
|
||||
ownerRoles: ["ceo", "finance_mgr"],
|
||||
ownerRoles: ["ceo", "finance"],
|
||||
managerId: null,
|
||||
});
|
||||
|
||||
@@ -248,7 +270,7 @@ describe("workflow assignee resolution", () => {
|
||||
ownerId: OWNER_ID,
|
||||
approvalRule: {
|
||||
level1_role: "manager",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
|
||||
在新工单中引用
屏蔽一个用户