修正CEO报销财务兜底与部署护栏(07-28-ceo-finance-fallback)
这个提交包含在:
@@ -16,6 +16,7 @@
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||
"seed:config": "node src/scripts/seed-config.js",
|
||||
"apply:ceo-finance-fallback": "node src/scripts/apply-ceo-finance-fallback.js",
|
||||
"seed:dev-users": "node src/scripts/seed-dev.js",
|
||||
"seed:dev": "node src/scripts/dev-seed-safety.js && npm run seed:config && npm run seed:dev-users",
|
||||
"seed:trialrun": "node src/scripts/seed-trialrun.js",
|
||||
|
||||
@@ -4,10 +4,12 @@ const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { ROLE_PERMISSIONS } = require("../constants/permissions");
|
||||
const { BUSINESS_APPROVAL_ROLE_CODES } = require("../utils/workflow");
|
||||
const {
|
||||
BUSINESS_APPROVAL_ROLE_CODES,
|
||||
OWNER_SELF_FALLBACK_ROLE_CODES,
|
||||
} = require("../utils/workflow");
|
||||
|
||||
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/);
|
||||
const OWNER_SELF_FALLBACK_ROLE_CODES = ["finance_mgr"];
|
||||
const REIMBURSEMENT_APPROVAL_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter(
|
||||
(roleCode) => (ROLE_PERMISSIONS[roleCode] ?? []).includes("expense.approve"),
|
||||
);
|
||||
@@ -144,7 +146,7 @@ async function approvalRulesRoutes(fastify) {
|
||||
level3_role: body.level3Role ?? null,
|
||||
owner_self_fallback_role: Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole")
|
||||
? body.ownerSelfFallbackRole ?? null
|
||||
: "finance_mgr",
|
||||
: "finance",
|
||||
enabled: body.enabled ?? true,
|
||||
priority,
|
||||
created_by: request.employee.id,
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const { Pool } = require("pg");
|
||||
|
||||
const APPLY_ENV_KEY = "CEO_FINANCE_FALLBACK_APPLY";
|
||||
const TARGET_BY_BAND = Object.freeze({
|
||||
small: Object.freeze({
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance",
|
||||
}),
|
||||
medium: Object.freeze({
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance",
|
||||
}),
|
||||
large: Object.freeze({
|
||||
level1_role: "manager",
|
||||
level2_role: "ceo",
|
||||
level3_role: "finance",
|
||||
owner_self_fallback_role: "finance",
|
||||
}),
|
||||
});
|
||||
|
||||
const EXPECTED_CURRENT_BY_BAND = Object.freeze({
|
||||
small: Object.freeze({
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
}),
|
||||
medium: Object.freeze({
|
||||
level1_role: "manager",
|
||||
level2_role: "finance_mgr",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
}),
|
||||
large: Object.freeze({
|
||||
level1_role: "manager",
|
||||
level2_role: "ceo",
|
||||
level3_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
}),
|
||||
});
|
||||
|
||||
function parseArgs(args) {
|
||||
const options = { apply: false, format: "text" };
|
||||
for (const arg of args) {
|
||||
if (arg === "--apply") options.apply = true;
|
||||
else if (arg === "--dry-run") options.apply = false;
|
||||
else if (arg === "--format=json") options.format = "json";
|
||||
else if (arg === "--help" || arg === "-h") options.help = true;
|
||||
else throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function getBand(row) {
|
||||
const min = Number(row.min_amount);
|
||||
const max = row.max_amount == null ? null : Number(row.max_amount);
|
||||
if (min === 0 && max === 500) return "small";
|
||||
if (min === 500 && max === 5000) return "medium";
|
||||
if (min === 5000 && max == null) return "large";
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameRoles(row, expected) {
|
||||
return ["level1_role", "level2_role", "level3_role", "owner_self_fallback_role"]
|
||||
.every((field) => (row[field] ?? null) === expected[field]);
|
||||
}
|
||||
|
||||
function buildRulePlan(rows) {
|
||||
if (!Array.isArray(rows) || rows.length !== 3) {
|
||||
throw new Error("approval_rule_count_drift");
|
||||
}
|
||||
|
||||
const plan = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
const band = getBand(row);
|
||||
if (!band || seen.has(band)) {
|
||||
throw new Error("approval_rule_amount_band_drift");
|
||||
}
|
||||
if (!sameRoles(row, EXPECTED_CURRENT_BY_BAND[band])) {
|
||||
throw new Error(`approval_rule_role_drift:${band}`);
|
||||
}
|
||||
seen.add(band);
|
||||
plan.push({ id: row.id, band, before: EXPECTED_CURRENT_BY_BAND[band], after: TARGET_BY_BAND[band] });
|
||||
}
|
||||
|
||||
if (seen.size !== 3) throw new Error("approval_rule_amount_band_drift");
|
||||
return plan.sort((left, right) => ["small", "medium", "large"].indexOf(left.band)
|
||||
- ["small", "medium", "large"].indexOf(right.band));
|
||||
}
|
||||
|
||||
function assertPeopleBaseline({ ceos, finances, admins }) {
|
||||
if (ceos.length !== 1) throw new Error("active_ceo_count_drift");
|
||||
if (ceos[0].direct_manager_id != null) throw new Error("ceo_manager_drift");
|
||||
if (!ceos[0].has_wecom_binding) throw new Error("ceo_binding_missing");
|
||||
if (ceos[0].roles.includes("finance") || ceos[0].roles.includes("finance_mgr")) {
|
||||
throw new Error("ceo_holds_fallback_role");
|
||||
}
|
||||
if (finances.length !== 1) throw new Error("active_non_ceo_finance_count_drift");
|
||||
if (!finances[0].has_wecom_binding) throw new Error("finance_binding_missing");
|
||||
if (admins.length !== 1) throw new Error("active_admin_actor_count_drift");
|
||||
}
|
||||
|
||||
async function loadRoleHolders(client, roleCode, excludedEmployeeId = null) {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT
|
||||
e.id::text,
|
||||
e.direct_manager_id::text,
|
||||
EXISTS (
|
||||
SELECT 1 FROM wecom_identity_bindings w WHERE w.employee_id = e.id
|
||||
) AS has_wecom_binding,
|
||||
COALESCE(array_agg(all_roles.role_code::text ORDER BY all_roles.role_code::text), ARRAY[]::text[]) AS roles
|
||||
FROM employees e
|
||||
JOIN employee_roles selected_role
|
||||
ON selected_role.employee_id = e.id
|
||||
AND selected_role.role_code = $1::"RoleCode"
|
||||
LEFT JOIN employee_roles all_roles ON all_roles.employee_id = e.id
|
||||
WHERE e.status = 'active'
|
||||
AND e.archived_at IS NULL
|
||||
AND ($2::uuid IS NULL OR e.id <> $2::uuid)
|
||||
GROUP BY e.id, e.direct_manager_id
|
||||
ORDER BY e.id
|
||||
`,
|
||||
[roleCode, excludedEmployeeId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function loadInvariantCounts(client) {
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM employees WHERE status = 'active' AND archived_at IS NULL) AS active_employees,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM employee_roles er
|
||||
JOIN employees e ON e.id = er.employee_id
|
||||
WHERE e.status = 'active' AND e.archived_at IS NULL
|
||||
) AS active_role_rows,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM wecom_identity_bindings w
|
||||
JOIN employees e ON e.id = w.employee_id
|
||||
WHERE e.status = 'active' AND e.archived_at IS NULL
|
||||
) AS active_bindings,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM workflow_instances
|
||||
WHERE context_data ? 'workflow_steps_snapshot'
|
||||
) AS workflow_snapshots
|
||||
`);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
function countsEqual(left, right) {
|
||||
return ["active_employees", "active_role_rows", "active_bindings", "workflow_snapshots"]
|
||||
.every((field) => Number(left[field]) === Number(right[field]));
|
||||
}
|
||||
|
||||
async function execute(options, pool = new Pool({ connectionString: process.env.DATABASE_URL })) {
|
||||
if (!process.env.DATABASE_URL) throw new Error("missing_database_url");
|
||||
if (options.apply && process.env[APPLY_ENV_KEY] !== "1") {
|
||||
throw new Error(`apply_requires_${APPLY_ENV_KEY}`);
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
let transactionOpen = false;
|
||||
try {
|
||||
await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
|
||||
transactionOpen = true;
|
||||
|
||||
const rulesResult = await client.query(`
|
||||
SELECT id::text, min_amount::text, max_amount::text,
|
||||
level1_role::text, level2_role::text, level3_role::text,
|
||||
owner_self_fallback_role::text
|
||||
FROM approval_rules
|
||||
WHERE enabled = true
|
||||
ORDER BY priority, id
|
||||
FOR UPDATE
|
||||
`);
|
||||
const plan = buildRulePlan(rulesResult.rows);
|
||||
|
||||
const ceos = await loadRoleHolders(client, "ceo");
|
||||
const finances = await loadRoleHolders(client, "finance", ceos[0]?.id ?? null);
|
||||
const admins = await loadRoleHolders(client, "admin");
|
||||
assertPeopleBaseline({ ceos, finances, admins });
|
||||
|
||||
const beforeCounts = await loadInvariantCounts(client);
|
||||
const requestId = `ceo-finance-fallback-${crypto.randomUUID()}`;
|
||||
for (const item of plan) {
|
||||
const updated = await client.query(
|
||||
`
|
||||
UPDATE approval_rules
|
||||
SET level1_role = $2::"RoleCode",
|
||||
level2_role = $3::"RoleCode",
|
||||
level3_role = $4::"RoleCode",
|
||||
owner_self_fallback_role = $5::"RoleCode",
|
||||
updated_by = $6::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING id::text
|
||||
`,
|
||||
[item.id, item.after.level1_role, item.after.level2_role, item.after.level3_role,
|
||||
item.after.owner_self_fallback_role, admins[0].id],
|
||||
);
|
||||
if (updated.rowCount !== 1) throw new Error(`approval_rule_update_failed:${item.band}`);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO audit_logs
|
||||
(request_id, actor_id, action, target_type, target_id, ip_address, user_agent, extra)
|
||||
VALUES
|
||||
($1, $2::uuid, 'APPROVAL_RULE_UPDATED', 'approval_rule', $3::uuid, NULL,
|
||||
'controlled-script/07-28-ceo-finance-fallback', $4::jsonb)
|
||||
`,
|
||||
[requestId, admins[0].id, item.id, JSON.stringify({
|
||||
task: "07-28-ceo-finance-fallback",
|
||||
band: item.band,
|
||||
before: item.before,
|
||||
after: item.after,
|
||||
})],
|
||||
);
|
||||
}
|
||||
|
||||
const afterRules = await client.query(`
|
||||
SELECT id::text, min_amount::text, max_amount::text,
|
||||
level1_role::text, level2_role::text, level3_role::text,
|
||||
owner_self_fallback_role::text
|
||||
FROM approval_rules
|
||||
WHERE enabled = true
|
||||
ORDER BY priority, id
|
||||
`);
|
||||
if (afterRules.rows.length !== 3 || afterRules.rows.some((row) => {
|
||||
const band = getBand(row);
|
||||
return !band || !sameRoles(row, TARGET_BY_BAND[band]);
|
||||
})) {
|
||||
throw new Error("approval_rule_postcondition_failed");
|
||||
}
|
||||
|
||||
const afterCounts = await loadInvariantCounts(client);
|
||||
if (!countsEqual(beforeCounts, afterCounts)) throw new Error("protected_invariant_changed");
|
||||
|
||||
if (options.apply) {
|
||||
await client.query("COMMIT");
|
||||
transactionOpen = false;
|
||||
} else {
|
||||
await client.query("ROLLBACK");
|
||||
transactionOpen = false;
|
||||
}
|
||||
|
||||
return {
|
||||
status: options.apply ? "APPLIED" : "DRY_RUN_PASS",
|
||||
rules: plan.length,
|
||||
auditRows: options.apply ? plan.length : 0,
|
||||
protectedCountsStable: true,
|
||||
realReimbursementsCreated: 0,
|
||||
wecomWrites: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (transactionOpen) await client.query("ROLLBACK").catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
process.stdout.write("Usage: node src/scripts/apply-ceo-finance-fallback.js [--dry-run|--apply] [--format=json]\n");
|
||||
return;
|
||||
}
|
||||
const result = await execute(options);
|
||||
process.stdout.write(options.format === "json" ? `${JSON.stringify(result)}\n` : [
|
||||
`[ceo-finance-fallback] status=${result.status}`,
|
||||
`[ceo-finance-fallback] rules=${result.rules} auditRows=${result.auditRows}`,
|
||||
`[ceo-finance-fallback] protectedCountsStable=${result.protectedCountsStable}`,
|
||||
].join("\n") + "\n");
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[ceo-finance-fallback] failed=${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EXPECTED_CURRENT_BY_BAND,
|
||||
TARGET_BY_BAND,
|
||||
assertPeopleBaseline,
|
||||
buildRulePlan,
|
||||
getBand,
|
||||
parseArgs,
|
||||
};
|
||||
@@ -56,7 +56,7 @@ const DEFAULT_APPROVAL_RULES = [
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance",
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
@@ -64,9 +64,9 @@ const DEFAULT_APPROVAL_RULES = [
|
||||
min_amount: "500",
|
||||
max_amount: "5000",
|
||||
level1_role: "manager",
|
||||
level2_role: "finance_mgr",
|
||||
level2_role: "finance",
|
||||
level3_role: null,
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance",
|
||||
priority: 20,
|
||||
},
|
||||
{
|
||||
@@ -75,8 +75,8 @@ const DEFAULT_APPROVAL_RULES = [
|
||||
max_amount: null,
|
||||
level1_role: "manager",
|
||||
level2_role: "ceo",
|
||||
level3_role: "finance_mgr",
|
||||
owner_self_fallback_role: "finance_mgr",
|
||||
level3_role: "finance",
|
||||
owner_self_fallback_role: "finance",
|
||||
priority: 30,
|
||||
},
|
||||
];
|
||||
@@ -563,7 +563,11 @@ async function seedExpenseTypes(prisma) {
|
||||
}
|
||||
|
||||
async function seedApprovalRules(prisma) {
|
||||
await prisma.approvalRule.deleteMany({});
|
||||
const existingCount = await prisma.approvalRule.count();
|
||||
if (existingCount > 0) {
|
||||
return { created: 0, preserved: existingCount };
|
||||
}
|
||||
|
||||
await prisma.approvalRule.createMany({
|
||||
data: DEFAULT_APPROVAL_RULES.map((item) => ({
|
||||
...item,
|
||||
@@ -572,6 +576,8 @@ async function seedApprovalRules(prisma) {
|
||||
updated_by: null,
|
||||
})),
|
||||
});
|
||||
|
||||
return { created: DEFAULT_APPROVAL_RULES.length, preserved: 0 };
|
||||
}
|
||||
|
||||
async function seedDocumentSequence(prisma) {
|
||||
@@ -1022,7 +1028,7 @@ async function main() {
|
||||
|
||||
try {
|
||||
await seedExpenseTypes(prisma);
|
||||
await seedApprovalRules(prisma);
|
||||
const approvalRuleSeed = await seedApprovalRules(prisma);
|
||||
await seedDocumentSequence(prisma);
|
||||
await seedDocumentCategories(prisma);
|
||||
await seedWorkflowDefinition(prisma);
|
||||
@@ -1037,7 +1043,9 @@ async function main() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("- approval rules: 3");
|
||||
console.log(
|
||||
`- approval rules: ${approvalRuleSeed.created} created / ${approvalRuleSeed.preserved} preserved`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("- document sequence: reimbursement(reset_period=none)");
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -1070,8 +1078,15 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_APPROVAL_RULES,
|
||||
seedApprovalRules,
|
||||
};
|
||||
|
||||
@@ -19,7 +19,8 @@ const BUSINESS_APPROVAL_ROLE_CODES = Object.freeze([
|
||||
"administration",
|
||||
]);
|
||||
const BUSINESS_APPROVAL_ROLE_CODE_SET = new Set(BUSINESS_APPROVAL_ROLE_CODES);
|
||||
const SUPPORTED_OWNER_SELF_FALLBACK_ROLES = new Set(["finance_mgr"]);
|
||||
const OWNER_SELF_FALLBACK_ROLE_CODES = Object.freeze(["finance", "finance_mgr"]);
|
||||
const SUPPORTED_OWNER_SELF_FALLBACK_ROLES = new Set(OWNER_SELF_FALLBACK_ROLE_CODES);
|
||||
const OWNER_SELF_FALLBACK_REASONS = new Set([
|
||||
"no_manager_found",
|
||||
"chain_broken",
|
||||
@@ -555,6 +556,7 @@ function toMoneyString(value) {
|
||||
|
||||
module.exports = {
|
||||
BUSINESS_APPROVAL_ROLE_CODES,
|
||||
OWNER_SELF_FALLBACK_ROLE_CODES,
|
||||
buildApprovalWorkflowSnapshot,
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
getWorkflowStepsSnapshot,
|
||||
|
||||
@@ -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({
|
||||
|
||||
在新工单中引用
屏蔽一个用户