OAFLOW1 支持负责人本人报销兜底审批
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户