修正CEO报销财务兜底与部署护栏(07-28-ceo-finance-fallback)
这个提交包含在:
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户