文件
hyb-oa/server/tests/unit/routes/reimbursements.detail.test.js
T

1324 行
48 KiB
JavaScript

const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const reimbursementRoutes = require("../../../src/routes/reimbursements");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
const OWNER_ID = "22222222-2222-4222-8222-222222222222";
const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333";
const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444";
const PENDING_INSTANCE_ID = "55555555-5555-4555-8555-555555555555";
function buildReimbursementRecord(overrides = {}) {
return {
id: REIMBURSEMENT_ID,
form_number: "BX20260410001",
submitter_id: SUBMITTER_ID,
owner_id: OWNER_ID,
department_id: DEPARTMENT_ID,
created_at: new Date("2026-04-10T09:00:00.000Z"),
reimbursement_date: new Date("2026-04-10T00:00:00.000Z"),
total_amount: "188.80",
status: "reviewing",
description: "客户拜访报销",
project_id: null,
linked_travel_request_id: null,
department: { id: DEPARTMENT_ID, name: "华东销售部" },
project: null,
travel_request: null,
owner: { id: OWNER_ID, employee_no: "E1001", name: "张三" },
submitter: { id: SUBMITTER_ID, employee_no: "E1002", name: "李四" },
items: [
{
id: "item-1",
expense_type_id: "expense-type-1",
expense_date: new Date("2026-04-09T00:00:00.000Z"),
amount: "188.80",
description: "客户打车",
expense_type: {
id: "expense-type-1",
code: "travel_taxi",
name: "打车费",
category: "travel",
enabled: true,
requires_invoice: true,
},
},
],
attachments: [],
invoice_pool_items: [
{
id: "invoice-1",
status: "bound",
invoice_code: "FP001",
invoice_number: "0001",
invoice_date: new Date("2026-04-09T00:00:00.000Z"),
seller_name: "上海供应商",
total_amount: "188.80",
original_name: "receipt.pdf",
linked_item_id: "item-1",
ocr_attempts: 1,
ocr_last_requested_at: new Date("2026-04-09T01:00:00.000Z"),
ocr_last_error: null,
ocr_completed_at: new Date("2026-04-09T01:00:01.000Z"),
},
],
workflow_instances: [
{
id: PENDING_INSTANCE_ID,
current_step_id: "finance_approve",
current_status: "pending",
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务审批" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务审批" },
],
},
},
workflow_tasks: [
{
id: "task-1",
step_name: "财务审批",
assignee_id: "finance-1",
assignee_role: "finance",
},
],
},
],
...overrides,
};
}
async function buildApp({
employeeId = OWNER_ID,
permissionCodes = ["expense.view_own"],
roleCodes = [],
reviewerTask = null,
reimbursement = buildReimbursementRecord(),
latestWorkflowInstance = {
id: PENDING_INSTANCE_ID,
current_step_id: "finance_approve",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
current_status: "pending",
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务审批" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务审批" },
],
},
},
workflow_tasks: [
{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status: "completed",
outcome: "approved",
comment: null,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T12:00:00.000Z"),
assignee_employee: { name: "王经理" },
completer_employee: { name: "王经理" },
},
{
id: "finance-task",
step_id: "finance_approve",
step_name: "财务审批",
assignee_role: "finance",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T12:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "财务专员" },
completer_employee: null,
},
],
},
workflowHistoryRows = [
{
id: "history-start",
action: "start",
comment: null,
payload: { step_id: "manager_approve" },
created_at: new Date("2026-04-10T10:00:00.000Z"),
actor: { name: "李四" },
workflow_task: { step_name: "主管审批" },
},
{
id: "history-approve",
action: "approve",
comment: "同意",
payload: { from_step_id: "manager_approve", to_step_id: "finance_approve" },
created_at: new Date("2026-04-10T12:00:00.000Z"),
actor: { name: "王经理" },
workflow_task: { step_name: "主管审批" },
},
{
id: "history-skip",
action: "skip",
comment: null,
payload: { step_id: "ceo_approve" },
created_at: new Date("2026-04-10T12:01:00.000Z"),
actor: { name: "系统" },
workflow_task: null,
},
],
paymentBatchItem = null,
paymentActors = [],
itemRecord = {
id: "77777777-7777-4777-8777-777777777777",
expense_type_id: "88888888-8888-4888-8888-888888888888",
asset_prior_approval_basis: null,
},
expenseType = {
id: "88888888-8888-4888-8888-888888888888",
enabled: true,
category: "assets",
},
} = {}) {
const app = Fastify();
const tx = {
$queryRaw: vi.fn(async () => [reimbursement]),
reimbursementForm: {
update: vi.fn(async () => ({ id: REIMBURSEMENT_ID })),
delete: vi.fn(),
},
expenseType: {
findUnique: vi.fn(async () => expenseType),
},
invoicePool: {
updateMany: vi.fn(),
},
reimbursementItem: {
findFirst: vi.fn(async () => itemRecord),
aggregate: vi.fn(async () => ({ _sum: { amount: "10.00" } })),
create: vi.fn(async ({ data }) => ({
id: itemRecord.id,
expense_type_id: data.expense_type_id,
expense_date: data.expense_date,
amount: data.amount,
description: data.description ?? null,
asset_prior_approval_basis: data.asset_prior_approval_basis,
})),
update: vi.fn(async ({ data }) => ({
id: itemRecord.id,
expense_type_id: data.expense_type_id ?? itemRecord.expense_type_id,
expense_date: data.expense_date ?? new Date("2026-04-10T00:00:00.000Z"),
amount: data.amount ?? "10.00",
description: data.description ?? "合成明细",
asset_prior_approval_basis: data.asset_prior_approval_basis,
})),
deleteMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
};
const prisma = {
$transaction: vi.fn(async (callback) => callback(tx)),
reimbursementForm: {
updateMany: vi.fn(async () => ({ count: 1 })),
findUnique: vi.fn(async ({ where }) => {
if (where.id !== REIMBURSEMENT_ID) {
return null;
}
return reimbursement;
}),
update: vi.fn(async ({ data }) => ({
id: REIMBURSEMENT_ID,
project_id: data.project_id ?? null,
project: data.project_id
? { id: data.project_id, project_no: "P-001", name: "合成项目" }
: null,
})),
},
featureFlag: {
findUnique: vi.fn(async () => ({
flag_key: "project_linking_v1",
override_mode: "inherit",
rollout_type: "global",
department_ids: [],
version: 1,
})),
},
workflowInstance: {
findFirst: vi.fn(async () => latestWorkflowInstance),
},
workflowHistory: {
findMany: vi.fn(async () => workflowHistoryRows),
},
entityRiskFlag: {
findMany: vi.fn(async () => []),
},
paymentBatchItem: {
findFirst: vi.fn(async () => paymentBatchItem),
},
employee: {
findMany: vi.fn(async () => paymentActors),
},
budgetPool: {
findMany: vi.fn(async () => []),
},
delegationGrant: {
findMany: vi.fn(async () => []),
},
workflowTask: {
findFirst: vi.fn(async () => reviewerTask),
},
};
app.decorate("prisma", prisma);
app.decorate("config", {
budgetControlMode: "warn",
appTimezone: "Asia/Shanghai",
projectLinkingEnabled: true,
});
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = {
id: employeeId,
permissionCodes,
roleCodes,
department_id: DEPARTMENT_ID,
};
});
app.decorate(
"hasPermission",
(employee, permissionCode) =>
(employee?.permissionCodes ?? []).includes("*") ||
(employee?.permissionCodes ?? []).includes(permissionCode),
);
app.decorate("requirePermission", (permissionCode) => async (request) => {
await app.authenticate(request);
if (!app.hasPermission(request.employee, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("assertEntityPermission", () => {});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(reimbursementRoutes, { prefix: "/api" });
return { app, prisma, tx };
}
describe("GET /api/reimbursements/:id detail enhancements", () => {
it("reads a saved unclassified draft without inventing an expense type", async () => {
const record = buildReimbursementRecord({ status: "draft", workflow_instances: [] });
record.items[0].expense_type_id = null;
record.items[0].expense_type = null;
const { app } = await buildApp({ reimbursement: record, latestWorkflowInstance: null });
try {
const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" } });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data.items[0]).toMatchObject({ expenseTypeId: null, expenseType: null, amount: "188.80" });
} finally { await app.close(); }
});
it("allows an assigned workflow reviewer to read evidence without expense view permissions", async () => {
const { app, prisma } = await buildApp({
employeeId: "99999999-9999-4999-8999-999999999999",
permissionCodes: ["expense.approve"],
roleCodes: ["finance"],
reviewerTask: { id: "review-task" },
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(prisma.workflowTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
workflow_instance: { entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID },
}),
select: { id: true },
}));
await app.close();
});
it("keeps an unrelated employee outside the reimbursement evidence projection", async () => {
const { app } = await buildApp({
employeeId: "99999999-9999-4999-8999-999999999999",
permissionCodes: ["expense.approve"],
reviewerTask: null,
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(403);
await app.close();
});
it("returns department, submittedAt, expanded invoices and printable approvalHistory", async () => {
const { app } = await buildApp();
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
department: {
id: DEPARTMENT_ID,
name: "华东销售部",
},
createdAt: "2026-04-10T09:00:00.000Z",
submittedAt: "2026-04-10T10:00:00.000Z",
travelSummary: {
isTravel: true,
purpose: null,
location: null,
startDate: null,
endDate: null,
},
invoices: [
{
id: "invoice-1",
invoiceCode: "FP001",
invoiceNumber: "0001",
invoiceDate: "2026-04-09",
sellerName: "上海供应商",
totalAmount: "188.80",
originalName: "receipt.pdf",
linkedItemId: "item-1",
amountEvidenceStatus: "verified",
},
],
invoiceAmountManualReviewRequired: false,
approvalHistory: [
{
id: "history-start",
stepName: "主管审批",
action: "start",
actorName: "李四",
comment: null,
createdAt: "2026-04-10T10:00:00.000Z",
},
{
id: "history-approve",
stepName: "主管审批",
action: "approve",
actorName: "王经理",
comment: "同意",
createdAt: "2026-04-10T12:00:00.000Z",
},
],
});
expect(response.json().data.workflowProgress).toMatchObject({
currentStepIndex: 1,
totalSteps: 2,
});
expect(response.json().data.processingProgress.stages).toEqual([
expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }),
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed", actorName: "王经理", result: "通过" }),
expect.objectContaining({ key: "approval_finance-task", label: "财务审批", state: "current", actorName: "财务专员", result: "处理中" }),
expect.objectContaining({ key: "payment_batch_created", label: "组建付款批次", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待付款批次创建并提交复核" }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]);
await app.close();
});
it("keeps the manual amount-review marker visible to a finance detail reader", async () => {
const reimbursement = buildReimbursementRecord({
invoice_pool_items: [{
id: "invoice-off",
status: "bound",
invoice_code: null,
invoice_number: null,
invoice_date: null,
seller_name: null,
total_amount: null,
original_name: "manual-review.pdf",
linked_item_id: "item-1",
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
}],
});
const { app } = await buildApp({
employeeId: "finance-reader",
permissionCodes: ["expense.view_all"],
reimbursement,
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
invoices: [expect.objectContaining({
id: "invoice-off",
amountEvidenceStatus: "manual_review",
})],
}));
await app.close();
});
it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => {
const reimbursement = buildReimbursementRecord({
status: "draft",
workflow_instances: [],
});
const { app } = await buildApp({
reimbursement,
latestWorkflowInstance: null,
workflowHistoryRows: [],
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.submittedAt).toBe(null);
expect(response.json().data.approvalHistory).toEqual([]);
expect(response.json().data.processingProgress.stages).toEqual([
expect.objectContaining({ key: "submitted", state: "current", actorName: "张三", detail: "待申请人提交" }),
expect.objectContaining({ key: "manager_approval", state: "pending", actorName: null }),
expect.objectContaining({ key: "finance_review", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_batch_created", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]);
await app.close();
});
it("keeps a third approval task and uses the role label when its configured name is only an ordinal", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "ceo_approve",
current_status: "pending",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务复核" },
{ id: "ceo_approve", name: "步骤3" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务复核" },
{ id: "ceo_approve", name: "步骤3" },
],
},
},
workflow_tasks: [
{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status: "completed",
outcome: "approved",
comment: null,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
assignee_employee: { name: "主管" },
completer_employee: { name: "主管" },
},
{
id: "finance-task",
step_id: "finance_approve",
step_name: "财务复核",
assignee_role: "finance",
status: "completed",
outcome: "approved",
comment: null,
created_at: new Date("2026-04-10T11:00:00.000Z"),
completed_at: new Date("2026-04-10T12:00:00.000Z"),
assignee_employee: { name: "财务" },
completer_employee: { name: "财务" },
},
{
id: "ceo-task",
step_id: "ceo_approve",
step_name: "步骤3",
assignee_role: "ceo",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T12:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "负责人" },
completer_employee: null,
},
],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages.slice(1, 4)).toEqual([
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed" }),
expect.objectContaining({ key: "approval_finance-task", label: "财务复核", state: "completed" }),
expect.objectContaining({ key: "approval_ceo-task", label: "负责人复核", state: "current", actorName: "负责人" }),
]);
await app.close();
});
it("projects legacy owner-self fallback task names as finance review", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "approval_1_finance_owner_self_fallback",
current_status: "pending",
created_at: new Date("2026-08-28T11:08:00.000Z"),
started_at: new Date("2026-08-28T11:08:00.000Z"),
context_data: {
workflow_steps_snapshot: [{
id: "approval_1_finance_owner_self_fallback",
name: "负责人本人申请兜底:finance",
type: "approval",
assignee_rule: { type: "role", value: "finance" },
}],
},
workflow_definition: { definition_json: { steps: [] } },
workflow_tasks: [{
id: "owner-fallback-task",
step_id: "approval_1_finance_owner_self_fallback",
step_name: "负责人本人申请兜底:finance",
assignee_role: "finance",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-08-28T11:08:00.000Z"),
completed_at: null,
assignee_employee: { name: "财务人员" },
completer_employee: null,
}],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[1]).toEqual(
expect.objectContaining({
key: "approval_owner-fallback-task",
label: "财务复核",
state: "current",
actorName: "财务人员",
detail: "待财务复核处理",
}),
);
await app.close();
});
it("appends future snapshot approvals that do not have workflow tasks yet", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "manager_approve",
current_status: "pending",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批", type: "approval", assignee_rule: { type: "department_manager" } },
{ id: "finance_approve", name: "财务复核", type: "approval", assignee_rule: { type: "role", value: "finance" } },
{ id: "ceo_approve", name: "步骤3", type: "approval", assignee_rule: { type: "role", value: "ceo" } },
],
},
workflow_definition: { definition_json: { steps: [] } },
workflow_tasks: [{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "主管" },
completer_employee: null,
}],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages.slice(1, 4)).toEqual([
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "current", actorName: "主管" }),
expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }),
expect.objectContaining({ key: "approval_pending_3", label: "负责人复核", state: "pending", actorName: null }),
]);
await app.close();
});
it("keeps multiple tasks with the same role as separate ordered approval stages", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "finance_final",
current_status: "pending",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "finance_first", name: "财务初审" },
{ id: "finance_final", name: "财务终审" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "finance_first", name: "财务初审" },
{ id: "finance_final", name: "财务终审" },
],
},
},
workflow_tasks: [
{
id: "finance-first-task",
step_id: "finance_first",
step_name: "finance_first",
assignee_role: "finance",
status: "completed",
outcome: "approved",
comment: "初审通过",
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
assignee_employee: { name: "初审人" },
completer_employee: { name: "初审人" },
},
{
id: "finance-final-task",
step_id: "finance_final",
step_name: "财务终审",
assignee_role: "finance",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T11:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "终审人" },
completer_employee: null,
},
],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages.slice(1, 3)).toEqual([
expect.objectContaining({ key: "approval_finance-first-task", label: "财务复核", actorName: "初审人", state: "completed" }),
expect.objectContaining({ key: "approval_finance-final-task", label: "财务终审", actorName: "终审人", state: "current" }),
]);
await app.close();
});
it("falls back to pending manager and finance placeholders when an instance has no tasks yet", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: null,
current_status: "pending",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务复核" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务复核" },
],
},
},
workflow_tasks: [],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages.slice(1, 3)).toEqual([
expect.objectContaining({ key: "approval_pending_1", label: "主管审批", state: "pending", actorName: null }),
expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }),
]);
await app.close();
});
it.each([
["draft", "current", "pending", "待付款复核通过后由财务登记,非审批环节"],
["reviewed", "completed", "current", "等待财务线下付款并上传回单,非审批环节"],
["executed", "completed", "completed", "付款和回单已留存"],
["failed", "completed", "exception", "付款登记失败,待财务核对后重试,非审批环节"],
["cancelled", "exception", "terminated", null],
])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => {
const paymentBatchItem = {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status,
entity_type: "reimbursement",
default_payment_channel: "bank",
payment_order_export_count: 0,
created_at: new Date("2026-04-11T07:00:00.000Z"),
created_by: "maker-1",
reviewed_at: status === "draft" || status === "cancelled" ? null : new Date("2026-04-11T08:00:00.000Z"),
reviewed_by: status === "draft" || status === "cancelled" ? null : "reviewer-1",
executed_at: ["executed", "failed"].includes(status) ? new Date("2026-04-12T08:00:00.000Z") : null,
executed_by: ["executed", "failed"].includes(status) ? "executor-1" : null,
cancelled_at: status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null,
cancelled_by: status === "cancelled" ? "reviewer-1" : null,
cancelled_reason: status === "cancelled" ? "批次资料需调整" : null,
items: [{ status: status === "executed" ? "executed" : "pending", payment_channel_override: null }],
_count: { receipt_attachments: status === "executed" ? 1 : 0 },
},
};
const { app } = await buildApp({
paymentBatchItem,
paymentActors: [
{ id: "reviewer-1", name: "实际复核人" },
{ id: "executor-1", name: "实际付款人" },
],
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
const stages = response.json().data.processingProgress.stages;
expect(stages[3]).toMatchObject({ key: "payment_batch_created", state: "completed", targetHref: null });
expect(stages[4]).toMatchObject({ state: reviewState, targetHref: null });
expect(stages[5]).toMatchObject({ state: paymentState, detail, targetHref: null });
if (status === "draft") expect(stages[4].actorName).toBe(null);
await app.close();
});
it("returns only a safe payment-batch link to users with existing batch-read permission", async () => {
const { app } = await buildApp({
permissionCodes: ["expense.view_own", "payment.batch.review"],
paymentBatchItem: {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status: "draft",
entity_type: "reimbursement",
default_payment_channel: "bank",
payment_order_export_count: 0,
created_at: new Date("2026-04-11T07:00:00.000Z"),
created_by: "maker-1",
reviewed_at: null,
reviewed_by: null,
executed_at: null,
executed_by: null,
cancelled_at: null,
cancelled_by: null,
cancelled_reason: null,
items: [{ status: "pending", payment_channel_override: null }],
_count: { receipt_attachments: 0 },
},
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[4]).toMatchObject({
detail: "待非制单人复核付款",
actorName: null,
targetHref: "/payment-batches?id=batch-1",
});
await app.close();
});
it("attaches the payment-batch link to exactly one status-owning stage", async () => {
const cases = [
{ status: "draft", reviewedAt: null, targetIndex: 4 },
{ status: "reviewed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 },
{ status: "executed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 },
{ status: "failed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 },
{ status: "cancelled", reviewedAt: null, targetIndex: 4 },
{ status: "cancelled", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 5 },
];
for (const item of cases) {
const { app } = await buildApp({
permissionCodes: ["expense.view_own", "payment.batch.review"],
paymentBatchItem: {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status: item.status,
entity_type: "reimbursement",
default_payment_channel: "bank",
payment_order_export_count: 0,
created_at: new Date("2026-04-11T07:00:00.000Z"),
created_by: "maker-1",
reviewed_at: item.reviewedAt,
reviewed_by: item.reviewedAt ? "reviewer-1" : null,
executed_at: ["executed", "failed"].includes(item.status) ? new Date("2026-04-12T08:00:00.000Z") : null,
executed_by: ["executed", "failed"].includes(item.status) ? "executor-1" : null,
cancelled_at: item.status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null,
cancelled_by: item.status === "cancelled" ? "reviewer-1" : null,
cancelled_reason: item.status === "cancelled" ? "批次资料需调整" : null,
items: [{ status: item.status === "executed" ? "executed" : "pending", payment_channel_override: null }],
_count: { receipt_attachments: item.status === "executed" ? 1 : 0 },
},
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
const stages = response.json().data.processingProgress.stages;
const linkedStageIndexes = stages
.map((stage, index) => stage.targetHref ? index : -1)
.filter((index) => index >= 0);
expect(linkedStageIndexes).toEqual([item.targetIndex]);
expect(stages[item.targetIndex].targetHref).toBe("/payment-batches?id=batch-1");
await app.close();
}
});
it("marks finance batch creation as the current responsibility after workflow approval", async () => {
const { app } = await buildApp({
reimbursement: buildReimbursementRecord({ status: "approved", workflow_instances: [] }),
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({
key: "payment_batch_created",
state: "current",
actorName: null,
result: "待组批",
detail: "待财务组建付款批次",
});
await app.close();
});
it("keeps a legacy paid reimbursement readable when no payment batch exists", async () => {
const { app } = await buildApp({
reimbursement: buildReimbursementRecord({
status: "paid",
paid_at: new Date("2026-04-12T08:00:00.000Z"),
paid_by_employee: { id: "finance-1", employee_no: "F001", name: "实际付款人" },
workflow_instances: [],
}),
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[5]).toMatchObject({
state: "completed",
actorName: "实际付款人",
occurredAt: "2026-04-12T08:00:00.000Z",
result: "已付款",
detail: "付款已完成,回单待留存",
});
await app.close();
});
it.each([
["returned", "退回", "请补充材料"],
["rejected", "驳回", "不符合报销要求"],
])("expands a %s workflow outcome with the actual handler and comment", async (outcome, result, comment) => {
const status = outcome === "rejected" ? "rejected" : "completed";
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "manager_approve",
current_status: outcome === "rejected" ? "rejected" : "terminated",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
context_data: { workflow_steps_snapshot: [{ id: "manager_approve", name: "主管审批" }] },
workflow_definition: { definition_json: { steps: [{ id: "manager_approve", name: "主管审批" }] } },
workflow_tasks: [{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status,
outcome,
comment,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
assignee_employee: { name: "王经理" },
completer_employee: { name: "王经理" },
}],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[1]).toMatchObject({
state: "exception",
actorName: "王经理",
result,
comment,
});
await app.close();
});
});
describe("POST /api/reimbursements/:id/link-project authorization", () => {
const projectId = "66666666-6666-4666-8666-666666666666";
it("rejects a view-all nonparticipant without changing the project", async () => {
const { app, prisma } = await buildApp({
employeeId: "finance-viewer",
permissionCodes: ["expense.view_all", "expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(403);
expect(prisma.reimbursementForm.updateMany).not.toHaveBeenCalled();
await app.close();
});
it.each(["approved", "paid"])("rejects participant project changes in %s status", async (status) => {
const { app, prisma } = await buildApp({
employeeId: OWNER_ID,
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(409);
expect(prisma.reimbursementForm.updateMany).not.toHaveBeenCalled();
await app.close();
});
it("allows a draft participant to change the project", async () => {
const { app, prisma } = await buildApp({
employeeId: SUBMITTER_ID,
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(200);
expect(prisma.reimbursementForm.updateMany).toHaveBeenCalledWith({
where: { id: REIMBURSEMENT_ID, deleted_at: null, status: { in: ["draft", "rejected"] } },
data: { project_id: projectId, updated_by: SUBMITTER_ID },
});
await app.close();
});
});
describe("reimbursement maintenance authorization", () => {
it("marks deleted historical detail and terminates next-action projections", async () => {
const { app } = await buildApp({
employeeId: SUBMITTER_ID,
permissionCodes: ["expense.view_own"],
reimbursement: buildReimbursementRecord({ status: "cancelled", deleted_at: new Date("2026-09-10T01:00:00Z") }),
});
try {
const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" } });
expect(response.statusCode).toBe(200);
const detail = response.json().data;
expect(detail.deletedAt).toBe("2026-09-10T01:00:00.000Z");
expect(detail.processingProgress.stages.some((stage) => ["pending", "current"].includes(stage.state))).toBe(false);
expect(detail.processingProgress.stages.at(-1)).toMatchObject({ key: "deleted", result: "已删除", state: "terminated" });
} finally {
await app.close();
}
});
const itemId = "77777777-7777-4777-8777-777777777777";
const expenseTypeId = "88888888-8888-4888-8888-888888888888";
it.each([
["PATCH", `/api/reimbursements/${REIMBURSEMENT_ID}`, { description: "合成修改" }, "expense.edit_own"],
["DELETE", `/api/reimbursements/${REIMBURSEMENT_ID}`, undefined, "expense.delete_own"],
["POST", `/api/reimbursements/${REIMBURSEMENT_ID}/items`, {
expenseTypeId,
expenseDate: "2026-04-10",
amount: "10.00",
description: "合成明细",
}, "expense.edit_own"],
["PATCH", `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, { amount: "20.00" }, "expense.edit_own"],
["DELETE", `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, undefined, "expense.edit_own"],
])("rejects a view-all nonparticipant for %s %s before mutations", async (method, url, payload, permission) => {
const { app, tx } = await buildApp({
employeeId: "finance-viewer",
permissionCodes: [permission, "expense.view_all"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method,
url,
headers: { authorization: "Bearer test" },
...(payload ? { payload } : {}),
});
expect(response.statusCode, response.body).toBe(403);
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.reimbursementForm.delete).not.toHaveBeenCalled();
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
expect(tx.reimbursementItem.create).not.toHaveBeenCalled();
expect(tx.reimbursementItem.update).not.toHaveBeenCalled();
expect(tx.reimbursementItem.deleteMany).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
});
describe("reimbursement asset item nullable approval-basis compatibility", () => {
const itemId = "77777777-7777-4777-8777-777777777777";
const assetExpenseTypeId = "88888888-8888-4888-8888-888888888888";
const officeExpenseTypeId = "99999999-9999-4999-8999-999999999999";
it("creates an assets item with an omitted basis and persists null", async () => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items`,
headers: { authorization: "Bearer test" },
payload: {
expenseTypeId: assetExpenseTypeId,
expenseDate: "2026-04-10",
amount: "10.00",
description: "合成设备",
},
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ asset_prior_approval_basis: null }),
}));
expect(response.json().data.item.assetPriorApprovalBasis).toBeNull();
await app.close();
});
it("edits an assets item whose stored basis is null while omitted and keeps null", async () => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
itemRecord: {
id: itemId,
expense_type_id: assetExpenseTypeId,
asset_prior_approval_basis: null,
},
});
const response = await app.inject({
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
headers: { authorization: "Bearer test" },
payload: { amount: "20.00" },
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ asset_prior_approval_basis: null }),
}));
await app.close();
});
it("preserves an existing assets basis when the field is omitted", async () => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
itemRecord: {
id: itemId,
expense_type_id: assetExpenseTypeId,
asset_prior_approval_basis: "历史依据",
},
});
const response = await app.inject({
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
headers: { authorization: "Bearer test" },
payload: { amount: "20.00" },
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ asset_prior_approval_basis: "历史依据" }),
}));
await app.close();
});
it.each([null, " "])("clears an assets basis when explicitly sent as %j", async (assetPriorApprovalBasis) => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
itemRecord: {
id: itemId,
expense_type_id: assetExpenseTypeId,
asset_prior_approval_basis: "历史依据",
},
});
const response = await app.inject({
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
headers: { authorization: "Bearer test" },
payload: { assetPriorApprovalBasis },
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ asset_prior_approval_basis: null }),
}));
await app.close();
});
it("clears an existing basis when switching to a non-assets type", async () => {
const { app, tx } = await buildApp({
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
itemRecord: {
id: itemId,
expense_type_id: assetExpenseTypeId,
asset_prior_approval_basis: "历史依据",
},
expenseType: { id: officeExpenseTypeId, enabled: true, category: "office" },
});
const response = await app.inject({
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`,
headers: { authorization: "Bearer test" },
payload: { expenseTypeId: officeExpenseTypeId },
});
expect(response.statusCode, response.body).toBe(200);
expect(tx.reimbursementItem.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
expense_type_id: officeExpenseTypeId,
asset_prior_approval_basis: null,
}),
}));
await app.close();
});
});