fix: 财务本人报销由总经理独立审批(09-04-reimbursement-state-gap-finance-view)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-12 20:03:29 +08:00
父节点 31ab9fbb2b
当前提交 229867f5fd
共修改 18 个文件,包含 629 行新增和 20 行删除
+2
查看文件
@@ -1,4 +1,6 @@
const ERROR_REASONS = [
{"code":"reimbursement.finance_owner_ceo_unavailable","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。","recovery":"follow_guidance"},
{"code":"reimbursement.finance_owner_ceo_required","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_prefill_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。","recovery":"follow_guidance"},
{"code":"reimbursement.items_description_required","domain":"reimbursement","audience":"user","defaultMessage":"请补充明细的支出说明后再提交。","recovery":"follow_guidance"},
{"code":"reimbursement.material_preview_limit","domain":"reimbursement","audience":"user","defaultMessage":"报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。","recovery":"view_individually"},
+7
查看文件
@@ -66,6 +66,7 @@ const {
} = require("../services/businessAttachmentGovernance");
const {
buildApprovalWorkflowSnapshot,
FINANCE_OWNER_APPROVAL_STEP_ID,
getWorkflowStepsSnapshot,
resolveActionableStep,
resolveWorkflowStepDisplayName,
@@ -955,6 +956,10 @@ function buildPendingWorkflowSummary(item, request, delegatedPrincipalIds) {
if (!pendingInstance || !pendingTask) {
return null;
}
if (pendingInstance.current_step_id === FINANCE_OWNER_APPROVAL_STEP_ID
&& !(request.employee.roleCodes ?? []).includes("ceo")) {
return null;
}
if (!isPendingWorkflowTaskActionable(request, pendingTask, delegatedPrincipalIds)) {
return null;
@@ -4349,6 +4354,8 @@ async function reimbursementRoutes(fastify) {
tx,
approvalRule,
ownerId: reimbursement.owner_id,
submitterId: reimbursement.submitter_id,
entityType: "reimbursement",
logger: fastify.log,
});
const { actionable, skipped } = await resolveActionableStep({
+31 -1
查看文件
@@ -1,10 +1,12 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
const {
BUSINESS_APPROVAL_ROLE_CODES,
FINANCE_OWNER_APPROVAL_STEP_ID,
getWorkflowStepsSnapshot,
isBusinessApprovalRoleCode,
resolveActionableStep,
@@ -685,6 +687,11 @@ function listAllowedActionsForTask(request, task, actionable) {
if (task.status !== "pending" || !actionable) {
return [];
}
if (task.workflow_instance?.entity_type === "reimbursement"
&& task.step_id === FINANCE_OWNER_APPROVAL_STEP_ID
&& !request.employee.roleCodes.includes("ceo")) {
return [];
}
const actions = [];
const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve");
@@ -1066,10 +1073,32 @@ function ensureNoSelfApproval(actorIds, applicantIds) {
});
}
async function ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds) {
if (workflowInstance.entity_type !== "reimbursement"
|| workflowInstance.current_step_id !== FINANCE_OWNER_APPROVAL_STEP_ID) {
return;
}
const eligible = await tx.employeeRole.findMany({
where: {
employee_id: { in: actorIds },
role_code: "ceo",
employee: { status: "active", archived_at: null },
},
select: { employee_id: true },
});
if (actorIds.some((actorId) => !eligible.some((holder) => holder.employee_id === actorId))) {
throw buildBusinessValidationError("reimbursement.finance_owner_ceo_required", {
workflow: "finance_owner_ceo_required",
}, 403);
}
}
async function ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment) {
ensureBusinessApproveRole(request.employee, workflowInstance.entity_type);
const applicantIds = await loadWorkflowApplicantIds(tx, workflowInstance);
ensureNoSelfApproval(uniq([request.employee.id, assignment?.principalId]), applicantIds);
const actorIds = uniq([request.employee.id, assignment?.principalId]);
ensureNoSelfApproval(actorIds, applicantIds);
await ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds);
return applicantIds;
}
@@ -3197,6 +3226,7 @@ async function workflowRoutes(fastify) {
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
await ensureTransferTargetEligible(tx, toApproverId, approvePermission);
ensureNoSelfApproval([toApproverId], applicantIds);
await ensureFinanceOwnerCeoActors(tx, workflowInstance, [toApproverId]);
if (toApproverId === request.employee.id) {
throw new AppError({
+24
查看文件
@@ -4,6 +4,7 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const {
buildApprovalWorkflowSnapshot,
buildFinanceOwnerApprovalSnapshot,
parseWorkflowSteps,
resolveActionableStep,
} = require("../utils/workflow");
@@ -92,6 +93,8 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config
where: { id: reimbursementId },
select: {
id: true,
owner_id: true,
submitter_id: true,
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
@@ -106,6 +109,22 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config
throw buildNotFoundError();
}
const blockingReasons = [];
try {
await buildFinanceOwnerApprovalSnapshot({
tx, ownerId: reimbursement.owner_id, submitterId: reimbursement.submitter_id,
});
} catch (error) {
if (!(error instanceof AppError) || error.details?.reason !== "finance_owner_ceo_unavailable") {
throw error;
}
blockingReasons.push(buildIssue(
"finance_owner_ceo_unavailable",
"缺少非申请人的总经理审批人",
"财务本人报销需由非归属人且非提交人的在职总经理审批,请联系管理员核实审批配置。",
));
}
const [invoices, attachmentCount] = await Promise.all([
tx.invoicePool.findMany({
where: { linked_reimbursement_id: reimbursementId, status: "bound" },
@@ -191,6 +210,7 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config
timelinessChecks,
assetHint: { required: assetItemCount > 0, itemCount: assetItemCount },
warnings,
blockingReasons,
};
}
@@ -200,6 +220,7 @@ function mergeReimbursementOperationalChecks(precheck, operationalChecks) {
evidenceSummary: operationalChecks.evidenceSummary,
timelinessChecks: operationalChecks.timelinessChecks,
assetHint: operationalChecks.assetHint,
blockingReasons: [...precheck.blockingReasons, ...operationalChecks.blockingReasons],
warnings: [...precheck.warnings, ...operationalChecks.warnings],
};
}
@@ -659,6 +680,7 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger
total_amount: true,
status: true,
owner_id: true,
submitter_id: true,
items: {
select: {
id: true,
@@ -729,6 +751,8 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger
tx,
approvalRule,
ownerId: reimbursement.owner_id,
submitterId: reimbursement.submitter_id,
entityType: "reimbursement",
logger,
});
const resolved = await resolveActionableStep({
+37 -1
查看文件
@@ -1,4 +1,7 @@
const { AppError } = require("./AppError");
const { buildBusinessValidationError } = require("./businessValidationError");
const FINANCE_OWNER_APPROVAL_STEP_ID = "approval_1_ceo_finance_owner";
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const MAX_MANAGER_DEPTH = 10;
@@ -199,8 +202,39 @@ function assertOwnerSelfFallbackRole(roleCode, ownerRoleCodes) {
return roleCode;
}
async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, logger }) {
async function buildFinanceOwnerApprovalSnapshot({ tx, ownerId, submitterId }) {
const ownerRoleCodes = await loadOwnerRoleCodes(tx, ownerId);
if (ownerRoleCodes?.has("finance")) {
const applicantIds = [...new Set([ownerId, submitterId].filter(Boolean))];
const ceo = await tx.employeeRole.findFirst({
where: {
role_code: "ceo",
employee_id: { notIn: applicantIds },
employee: { status: "active", archived_at: null },
},
select: { employee_id: true },
});
if (!ceo) {
throw buildBusinessValidationError("reimbursement.finance_owner_ceo_unavailable", {
workflow: "no_actionable_approver", reason: "finance_owner_ceo_unavailable",
}, 409);
}
return [{
id: FINANCE_OWNER_APPROVAL_STEP_ID,
name: "总经理审批",
type: "approval",
assignee_rule: { type: "role", value: "ceo" },
}];
}
return null;
}
async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, submitterId, entityType, logger }) {
const rawSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
if (entityType === "reimbursement") {
const financeOwnerSteps = await buildFinanceOwnerApprovalSnapshot({ tx, ownerId, submitterId });
if (financeOwnerSteps) return financeOwnerSteps;
}
const hasManagerStep = rawSteps.some((step) => isDepartmentManagerRule(step.assignee_rule));
if (!hasManagerStep || !approvalRule.owner_self_fallback_role) {
@@ -567,9 +601,11 @@ function toMoneyString(value) {
}
module.exports = {
FINANCE_OWNER_APPROVAL_STEP_ID,
BUSINESS_APPROVAL_ROLE_CODES,
OWNER_SELF_FALLBACK_ROLE_CODES,
buildApprovalWorkflowSnapshot,
buildFinanceOwnerApprovalSnapshot,
buildWorkflowStepsFromApprovalRule,
getWorkflowStepsSnapshot,
isBusinessApprovalRoleCode,
@@ -1,4 +1,6 @@
{
"reimbursement.finance_owner_ceo_unavailable": "财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。",
"reimbursement.finance_owner_ceo_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。",
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
"reimbursement.items_description_required": "请补充明细的支出说明后再提交。",
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
@@ -6,9 +6,15 @@ const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
function buildTx() {
return {
employee: {
findUnique: vi.fn(async () => ({ roles: [{ role_code: "employee" }] })),
},
employeeRole: { findFirst: vi.fn(async () => null) },
reimbursementForm: {
findUnique: vi.fn(async () => ({
id: REIMBURSEMENT_ID,
owner_id: REIMBURSEMENT_ID,
submitter_id: REIMBURSEMENT_ID,
items: [
{
id: "item-office",
@@ -37,6 +43,21 @@ function buildTx() {
}
describe("buildReimbursementOperationalChecks", () => {
it("reports missing CEO as an operational blocker independently of the budget feature", async () => {
const tx = buildTx();
tx.employee.findUnique.mockResolvedValue({ roles: [{ role_code: "finance" }] });
const result = await buildReimbursementOperationalChecks(tx, {
reimbursementId: REIMBURSEMENT_ID, config: { appTimezone: "Asia/Shanghai" },
});
expect(result.blockingReasons).toEqual([expect.objectContaining({ code: "finance_owner_ceo_unavailable" })]);
expect(tx.employeeRole.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: {
role_code: "ceo", employee_id: { notIn: [REIMBURSEMENT_ID] },
employee: { status: "active", archived_at: null },
},
}));
});
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-04T04:00:00.000Z"));
+44
查看文件
@@ -131,6 +131,50 @@ describe("workflow assignee resolution", () => {
});
describe("buildApprovalWorkflowSnapshot", () => {
it.each([
["small", "finance", null],
["large", "ceo", "finance"],
])("uses exactly one CEO step for finance-owned reimbursement %s", async (_name, level2, level3) => {
const tx = buildTx({ ownerRoles: ["finance", "employee"], managerId: MANAGER_ID });
const steps = await workflowUtils.buildApprovalWorkflowSnapshot({
tx, entityType: "reimbursement", ownerId: OWNER_ID, submitterId: MANAGER_ID,
approvalRule: { level1_role: "manager", level2_role: level2, level3_role: level3 },
});
expect(steps).toEqual([{
id: "approval_1_ceo_finance_owner", name: "总经理审批", type: "approval",
assignee_rule: { type: "role", value: "ceo" },
}]);
expect(tx.employeeRole.findFirst).toHaveBeenCalledWith({
where: {
role_code: "ceo",
employee_id: { notIn: [OWNER_ID, MANAGER_ID] },
employee: { status: "active", archived_at: null },
},
select: { employee_id: true },
});
});
it.each([["finance"], ["finance", "ceo"]])("blocks finance owner roles %j without non-applicant CEO", async (...ownerRoles) => {
const tx = buildTx({ ownerRoles, fallbackHasHolder: false });
await expect(workflowUtils.buildApprovalWorkflowSnapshot({
tx, entityType: "reimbursement", ownerId: OWNER_ID, submitterId: OWNER_ID,
approvalRule: { level1_role: "finance" },
})).rejects.toMatchObject({
statusCode: 409,
details: { workflow: "no_actionable_approver", reason: "finance_owner_ceo_unavailable" },
});
});
it("does not simplify other entities or a finance submitter's employee-owned form", async () => {
for (const [entityType, ownerRoles] of [["purchase_request", ["finance"]], ["reimbursement", ["employee"]]]) {
const steps = await workflowUtils.buildApprovalWorkflowSnapshot({
tx: buildTx({ ownerRoles }), entityType, ownerId: OWNER_ID, submitterId: FINANCE_MGR_ID,
approvalRule: { level1_role: "manager", level2_role: "ceo", level3_role: "finance" },
});
expect(steps).toHaveLength(3);
}
});
it("keeps ordinary employee approval snapshots unchanged", async () => {
const tx = buildTx({ ownerRoles: ["employee"], managerId: MANAGER_ID });