774 行
27 KiB
JavaScript
774 行
27 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,
|
|
},
|
|
},
|
|
],
|
|
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",
|
|
},
|
|
],
|
|
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"],
|
|
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 = [],
|
|
} = {}) {
|
|
const app = Fastify();
|
|
const prisma = {
|
|
reimbursementForm: {
|
|
findUnique: vi.fn(async ({ where }) => {
|
|
if (where.id !== REIMBURSEMENT_ID) {
|
|
return null;
|
|
}
|
|
return reimbursement;
|
|
}),
|
|
},
|
|
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 () => []),
|
|
},
|
|
};
|
|
|
|
app.decorate("prisma", prisma);
|
|
app.decorate("config", {
|
|
budgetControlMode: "warn",
|
|
appTimezone: "Asia/Shanghai",
|
|
});
|
|
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 };
|
|
}
|
|
|
|
describe("GET /api/reimbursements/:id detail enhancements", () => {
|
|
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",
|
|
},
|
|
],
|
|
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_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }),
|
|
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
|
|
]);
|
|
|
|
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_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("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", "pending", "待财务重新创建付款批次"],
|
|
])("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,
|
|
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,
|
|
_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({ state: reviewState, targetHref: null });
|
|
expect(stages[4]).toMatchObject({ state: paymentState, detail, targetHref: null });
|
|
if (status === "draft") expect(stages[3].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",
|
|
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,
|
|
_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[3]).toMatchObject({
|
|
detail: "所在付款批次待非制单人复核",
|
|
actorName: null,
|
|
targetHref: "/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({
|
|
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[4]).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();
|
|
});
|
|
});
|