const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const cutover = require("../../../src/services/cutover"); const delegationUtils = require("../../../src/utils/delegation"); const ensureCutoverAllowedMock = vi.fn(); const findActiveDelegationForDelegateMock = vi.fn(); const listActiveDelegationPrincipalsMock = vi.fn(); const originalEnsureCutoverAllowed = cutover.ensureCutoverAllowed; const originalFindActiveDelegationForDelegate = delegationUtils.findActiveDelegationForDelegate; const originalListActiveDelegationPrincipals = delegationUtils.listActiveDelegationPrincipals; cutover.ensureCutoverAllowed = ensureCutoverAllowedMock; delegationUtils.findActiveDelegationForDelegate = findActiveDelegationForDelegateMock; delegationUtils.listActiveDelegationPrincipals = listActiveDelegationPrincipalsMock; delete require.cache[require.resolve("../../../src/routes/reimbursements")]; const reimbursementRoutes = require("../../../src/routes/reimbursements"); const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const OWNER_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const DEPARTMENT_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; const TEMPLATE_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; const TRAVEL_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; const ALT_TRAVEL_ID = "ffffffff-ffff-4fff-8fff-ffffffffffff"; const AIRFARE_TYPE_ID = "11111111-1111-4111-8111-111111111111"; const TRAIN_TYPE_ID = "22222222-2222-4222-8222-222222222222"; const BUS_TYPE_ID = "33333333-3333-4333-8333-333333333333"; const TAXI_TYPE_ID = "44444444-4444-4444-8444-444444444444"; const TRANSPORT_TYPE_ID = "55555555-5555-4555-8555-555555555555"; const MEAL_ALLOWANCE_TYPE_ID = "66666666-6666-4666-8666-666666666666"; function cloneDate(value) { return value ? new Date(value) : value; } function cloneTravelRequest(item) { return { ...item, start_date: cloneDate(item.start_date), end_date: cloneDate(item.end_date), created_at: cloneDate(item.created_at), segments: (item.segments ?? []).map((segment) => ({ ...segment, created_at: cloneDate(segment.created_at) })), linked_reimbursements: (item.linked_reimbursements ?? []).map((record) => ({ ...record })), travel_orders: (item.travel_orders ?? []).map((record) => ({ ...record })), }; } function buildTemplate(overrides = {}) { return { id: TEMPLATE_ID, name: "国内差旅模板", enabled: true, items: [ { expenseTypeId: AIRFARE_TYPE_ID, description: "机票占位", defaultAmount: "888.80", }, { expenseTypeId: BUS_TYPE_ID, description: "大巴车票占位", }, ], ...overrides, }; } function buildTravelRequest(overrides = {}) { return cloneTravelRequest({ id: TRAVEL_ID, request_no: "TR-20260408-001", purpose: "上海客户拜访", applicant_employee_id: ACTOR_ID, status: "approved", start_date: new Date("2026-04-10T00:00:00.000Z"), end_date: new Date("2026-04-12T00:00:00.000Z"), estimated_amount: "3600.00", created_at: new Date("2026-04-08T12:00:00.000Z"), segments: [ { id: "segment-1", sort_order: 1, segment_role: "outbound", transport_type: "flight", from_city: "上海", to_city: "深圳", created_at: new Date("2026-04-08T12:00:00.000Z"), }, { id: "segment-2", sort_order: 2, segment_role: "return", transport_type: "bus", from_city: "深圳", to_city: "上海", created_at: new Date("2026-04-08T12:01:00.000Z"), }, ], linked_reimbursements: [], travel_orders: [], ...overrides, }); } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, department_id: DEPARTMENT_ID, roleCodes: [], permissionCodes: ["expense.create"], }; const state = { reimbursements: [], reimbursementItems: [], templates: (options.templates ?? [buildTemplate()]).map((item) => ({ ...item, items: Array.isArray(item.items) ? item.items.map((entry) => ({ ...entry })) : [], })), employees: options.employees ?? [ { id: ACTOR_ID, department_id: DEPARTMENT_ID, status: "active" }, { id: OWNER_ID, department_id: DEPARTMENT_ID, status: "active" }, ], expenseTypes: options.expenseTypes ?? [ { id: AIRFARE_TYPE_ID, code: "travel_airfare", name: "机票", category: "travel", enabled: true }, { id: TRAIN_TYPE_ID, code: "travel_train", name: "火车票", category: "travel", enabled: true }, { id: BUS_TYPE_ID, code: "travel_bus", name: "大巴车票", category: "travel", enabled: true }, { id: TAXI_TYPE_ID, code: "travel_taxi", name: "打车费", category: "travel", enabled: true }, { id: TRANSPORT_TYPE_ID, code: "transport", name: "legacy-交通费", category: "travel", enabled: true }, { id: MEAL_ALLOWANCE_TYPE_ID, code: "travel_meal", name: "伙食补助费", category: "travel", enabled: true, requires_invoice: false }, ], travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)), auditLogs: [], sequenceNumber: 0, }; function buildReimbursementSelect(record, select) { if (!select) { return record; } return { ...(select.id ? { id: record.id } : {}), ...(select.status ? { status: record.status } : {}), ...(select.reimbursement_date ? { reimbursement_date: record.reimbursement_date } : {}), ...(select.description ? { description: record.description } : {}), ...(select.total_amount ? { total_amount: record.total_amount } : {}), ...(select.form_number ? { form_number: record.form_number } : {}), ...(select.owner_id ? { owner_id: record.owner_id } : {}), ...(select.submitter_id ? { submitter_id: record.submitter_id } : {}), ...(select.linked_travel_request_id ? { linked_travel_request_id: record.linked_travel_request_id ?? null } : {}), ...(select.travel_request ? { travel_request: record.linked_travel_request_id ? (() => { const travelRequest = state.travelRequests.find((item) => item.id === record.linked_travel_request_id); return travelRequest ? { id: travelRequest.id, request_no: travelRequest.request_no, purpose: travelRequest.purpose, start_date: travelRequest.start_date, end_date: travelRequest.end_date, estimated_amount: travelRequest.estimated_amount, } : null; })() : null, } : {}), }; } function buildExpenseTypeMap() { return new Map(state.expenseTypes.map((item) => [item.id, item])); } function buildPrismaLike(stateRef) { return { employee: { findUnique: vi.fn(async ({ where }) => stateRef.employees.find((item) => item.id === where.id) ?? null), }, reimbursementTemplate: { findFirst: vi.fn(async ({ where }) => { return stateRef.templates.find((item) => item.id === where.id && item.enabled === where.enabled) ?? null; }), }, expenseType: { findMany: vi.fn(async ({ where }) => { const ids = where?.id?.in ?? null; const codes = where?.code?.in ?? null; return stateRef.expenseTypes.filter((item) => { if (where?.enabled === true && !item.enabled) { return false; } if (where?.requires_invoice === true && item.requires_invoice !== true) { return false; } if (ids && !ids.includes(item.id)) { return false; } if (codes && !codes.includes(item.code)) { return false; } return true; }); }), }, travelRequest: { findMany: vi.fn(async ({ where }) => { return stateRef.travelRequests .filter((item) => { if (where?.applicant_employee_id && item.applicant_employee_id !== where.applicant_employee_id) { return false; } if (where?.status && item.status !== where.status) { return false; } if (where?.linked_reimbursements?.none && item.linked_reimbursements.length > 0) { return false; } if (where?.travel_orders?.none) { return item.travel_orders.every((order) => order.linked_reimbursement_id == null); } return true; }) .map((item) => ({ id: item.id, request_no: item.request_no, purpose: item.purpose, start_date: item.start_date, end_date: item.end_date, estimated_amount: item.estimated_amount, })); }), findUnique: vi.fn(async ({ where }) => { const found = stateRef.travelRequests.find((item) => item.id === where.id) ?? null; return found ? cloneTravelRequest(found) : null; }), }, documentSequence: { findUnique: vi.fn(async () => null), upsert: vi.fn(async ({ create, update }) => { stateRef.sequenceNumber = (stateRef.sequenceNumber ?? 0) + 1; return { document_type: create.document_type, prefix: update.prefix ?? create.prefix, date_format: update.date_format ?? create.date_format, separator: update.separator ?? create.separator, number_length: update.number_length ?? create.number_length, reset_period: update.reset_period ?? create.reset_period, current_number: stateRef.sequenceNumber, last_reset_date: null, updated_at: new Date("2026-04-08T12:00:00.000Z"), updated_by: update.updated_by ?? create.updated_by ?? null, }; }), }, documentCategory: { findUnique: vi.fn(async () => null), findFirst: vi.fn(async () => null), }, reimbursementForm: { create: vi.fn(async ({ data, select }) => { const record = { id: `reimbursement-${stateRef.reimbursements.length + 1}`, form_number: null, submitter_id: data.submitter_id, owner_id: data.owner_id, department_id: data.department_id, reimbursement_date: cloneDate(data.reimbursement_date), total_amount: "0.00", status: "draft", description: data.description ?? null, linked_travel_request_id: data.linked_travel_request_id ?? null, }; stateRef.reimbursements.push(record); return buildReimbursementSelect(record, select); }), update: vi.fn(async ({ where, data }) => { const target = stateRef.reimbursements.find((item) => item.id === where.id); if (!target) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } Object.assign(target, data); return { ...target }; }), }, reimbursementItem: { createMany: vi.fn(async ({ data }) => { const baseLength = stateRef.reimbursementItems.length; data.forEach((item, index) => { stateRef.reimbursementItems.push({ id: `item-${baseLength + index + 1}`, reimbursement_id: item.reimbursement_id, expense_type_id: item.expense_type_id, expense_date: cloneDate(item.expense_date), amount: item.amount, description: item.description, created_at: new Date(`2026-04-08T12:00:${String(baseLength + index).padStart(2, "0")}.000Z`), }); }); return { count: data.length }; }), findMany: vi.fn(async ({ where }) => { const expenseTypeMap = buildExpenseTypeMap(); return stateRef.reimbursementItems .filter((item) => item.reimbursement_id === where.reimbursement_id) .map((item) => ({ id: item.id, expense_type_id: item.expense_type_id, expense_date: item.expense_date, amount: item.amount, description: item.description, expense_type: expenseTypeMap.get(item.expense_type_id) ?? null, })); }), aggregate: vi.fn(async ({ where }) => { const total = stateRef.reimbursementItems .filter((item) => item.reimbursement_id === where.reimbursement_id) .reduce((sum, item) => sum + Number(item.amount ?? 0), 0); return { _sum: { amount: total.toFixed(2) } }; }), }, auditLog: { create: vi.fn(async ({ data }) => { stateRef.auditLogs.push(data); return { id: `audit-${stateRef.auditLogs.length}` }; }), }, }; } const prisma = buildPrismaLike(state); prisma.$transaction = vi.fn(async (callback) => { const txState = { reimbursements: state.reimbursements.map((item) => ({ ...item, reimbursement_date: cloneDate(item.reimbursement_date) })), reimbursementItems: state.reimbursementItems.map((item) => ({ ...item, expense_date: cloneDate(item.expense_date), created_at: cloneDate(item.created_at) })), templates: state.templates.map((item) => ({ ...item, items: item.items.map((entry) => ({ ...entry })) })), employees: state.employees.map((item) => ({ ...item })), expenseTypes: state.expenseTypes.map((item) => ({ ...item })), travelRequests: state.travelRequests.map((item) => cloneTravelRequest(item)), auditLogs: state.auditLogs.map((item) => ({ ...item })), sequenceNumber: state.sequenceNumber, }; const tx = buildPrismaLike(txState); const result = await callback(tx); state.reimbursements = txState.reimbursements; state.reimbursementItems = txState.reimbursementItems; state.templates = txState.templates; state.employees = txState.employees; state.expenseTypes = txState.expenseTypes; state.travelRequests = txState.travelRequests; state.auditLogs = txState.auditLogs; state.sequenceNumber = txState.sequenceNumber; return result; }); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", budgetControlMode: "warn", imNotifierEnabled: false, emailEnabled: false, }); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("hasPermission", (employee, permissionCode) => { const permissionCodes = employee?.permissionCodes ?? []; return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; if (!app.hasPermission(actor, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.setErrorHandler((error, request, reply) => { reply.status(error.statusCode || 500).send({ requestId: request.id, error: { code: error.code || "SYS_001", message: error.message, details: error.details ?? null, }, }); }); await app.register(reimbursementRoutes, { prefix: "/api" }); return { app, state, prisma }; } describe("reimbursement create enhancements", () => { afterAll(() => { cutover.ensureCutoverAllowed = originalEnsureCutoverAllowed; delegationUtils.findActiveDelegationForDelegate = originalFindActiveDelegationForDelegate; delegationUtils.listActiveDelegationPrincipals = originalListActiveDelegationPrincipals; }); beforeEach(() => { vi.clearAllMocks(); ensureCutoverAllowedMock.mockResolvedValue(undefined); findActiveDelegationForDelegateMock.mockResolvedValue(null); listActiveDelegationPrincipalsMock.mockResolvedValue([]); }); it("keeps blank reimbursement creation working", async () => { const { app, state } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { description: "空白报销", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ description: "空白报销", linkedTravelRequestId: null, items: [], totalAmount: "0.00", }), ); expect(state.reimbursementItems).toHaveLength(0); await app.close(); }); it.each(["reimbursementDate", "travelStartDate", "travelEndDate"])("rejects an overflowing %s before draft writes", async (field) => { const { app, state, prisma } = await buildApp(); try { const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { [field]: "2026-02-30" } }); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe("reimbursement.invalid_input"); expect(response.json().error.details.fieldErrors[field]).toContain("invalid_calendar_date"); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(state.reimbursements).toEqual([]); expect(state.auditLogs).toEqual([]); } finally { await app.close(); } }); it.each(["2024-02-29", "2026-04-30"])("stores valid reimbursement date %s unchanged", async (reimbursementDate) => { const { app, state } = await buildApp(); try { const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { reimbursementDate } }); expect(response.statusCode).toBe(200); expect(state.reimbursements[0].reimbursement_date.toISOString()).toBe(`${reimbursementDate}T00:00:00.000Z`); } finally { await app.close(); } }); it("creates reimbursement items from template", async () => { const { app, state } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { templateId: TEMPLATE_ID, reimbursementDate: "2026-04-09", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ totalAmount: "888.80", items: [ expect.objectContaining({ expenseTypeId: AIRFARE_TYPE_ID, amount: "888.80", expenseDate: "2026-04-09", }), expect.objectContaining({ expenseTypeId: BUS_TYPE_ID, amount: "0.00", expenseDate: "2026-04-09", }), ], }), ); expect(state.reimbursementItems).toHaveLength(2); await app.close(); }); it("keeps invoice-free allowance types available to reimbursement templates", async () => { const { app, state } = await buildApp({ templates: [buildTemplate({ items: [{ expenseTypeId: MEAL_ALLOWANCE_TYPE_ID, description: "伙食补助", defaultAmount: "300.00", }], })], }); const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { templateId: TEMPLATE_ID, reimbursementDate: "2026-04-09" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.items).toEqual([ expect.objectContaining({ expenseTypeId: MEAL_ALLOWANCE_TYPE_ID, amount: "300.00", }), ]); expect(state.reimbursementItems).toHaveLength(1); await app.close(); }); it("rejects templateId and linkedTravelRequestId when both are provided", async () => { const { app } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { templateId: TEMPLATE_ID, linkedTravelRequestId: TRAVEL_ID, }, }); expect(response.statusCode).toBe(400); await app.close(); }); it("creates reimbursement linked to approved travel request with generated items", async () => { const { app, state } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { linkedTravelRequestId: TRAVEL_ID, reimbursementDate: "2026-04-11", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ description: "差旅报销 - TR-20260408-001 上海客户拜访", linkedTravelRequestId: TRAVEL_ID, linkedTravelRequest: expect.objectContaining({ requestNo: "TR-20260408-001", estimatedAmount: "3600.00", }), items: [ expect.objectContaining({ expenseTypeId: AIRFARE_TYPE_ID, description: "去程 航班 上海→深圳", amount: "0.00", }), expect.objectContaining({ expenseTypeId: BUS_TYPE_ID, description: "返程 大巴 深圳→上海", amount: "0.00", }), ], }), ); expect(state.reimbursements[0].linked_travel_request_id).toBe(TRAVEL_ID); await app.close(); }); it("rejects travel request linking when request is not approved, not owned, or already linked", async () => { const { app } = await buildApp({ travelRequests: [ buildTravelRequest({ id: TRAVEL_ID, status: "draft" }), buildTravelRequest({ id: ALT_TRAVEL_ID, applicant_employee_id: OWNER_ID }), buildTravelRequest({ id: "99999999-9999-4999-8999-999999999999", linked_reimbursements: [{ id: "reimbursement-x" }], }), buildTravelRequest({ id: "88888888-8888-4888-8888-888888888888", travel_orders: [{ id: "travel-order-1", linked_reimbursement_id: "legacy-linked-reimbursement" }], }), ], }); const notApprovedResponse = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { linkedTravelRequestId: TRAVEL_ID, }, }); const notOwnedResponse = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { linkedTravelRequestId: ALT_TRAVEL_ID, }, }); const linkedByNewColumnResponse = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { linkedTravelRequestId: "99999999-9999-4999-8999-999999999999", }, }); const linkedByLegacyOrderResponse = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { linkedTravelRequestId: "88888888-8888-4888-8888-888888888888", }, }); expect(notApprovedResponse.statusCode).toBe(409); expect(notOwnedResponse.statusCode).toBe(409); expect(linkedByNewColumnResponse.statusCode).toBe(409); expect(linkedByLegacyOrderResponse.statusCode).toBe(409); await app.close(); }); it("rejects travel request linking when mapped expense type is missing", async () => { const { app } = await buildApp({ travelRequests: [ buildTravelRequest({ segments: [ { id: "segment-x", sort_order: 1, segment_role: "outbound", transport_type: "other", from_city: "上海", to_city: "杭州", created_at: new Date("2026-04-08T12:02:00.000Z"), }, ], }), ], expenseTypes: [ { id: AIRFARE_TYPE_ID, code: "travel_airfare", name: "机票", category: "travel", enabled: true }, ], }); const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { linkedTravelRequestId: TRAVEL_ID, }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual( expect.objectContaining({ linkedTravelRequestId: "missing_expense_type_mapping", expenseTypeCode: "transport", }), ); await app.close(); }); it("lists only linkable approved travel requests and supports proxy owner", async () => { findActiveDelegationForDelegateMock.mockResolvedValue({ id: "delegation-1", }); const { app } = await buildApp({ actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, roleCodes: ["administration"], permissionCodes: ["expense.create", "expense.proxy_submit"], }, travelRequests: [ buildTravelRequest({ id: TRAVEL_ID, applicant_employee_id: OWNER_ID, request_no: "TR-OWNER-001", }), buildTravelRequest({ id: ALT_TRAVEL_ID, applicant_employee_id: OWNER_ID, linked_reimbursements: [{ id: "linked-reimbursement" }], }), buildTravelRequest({ id: "77777777-7777-4777-8777-777777777777", applicant_employee_id: OWNER_ID, travel_orders: [{ id: "travel-order-2", linked_reimbursement_id: "legacy-link" }], }), ], }); const response = await app.inject({ method: "GET", url: `/api/reimbursements/linkable-travel-requests?ownerEmployeeId=${OWNER_ID}`, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ ownerEmployeeId: OWNER_ID, items: [ expect.objectContaining({ id: TRAVEL_ID, requestNo: "TR-OWNER-001", }), ], }); await app.close(); }); it("supports proxy owner when linking travel request during creation", async () => { findActiveDelegationForDelegateMock.mockResolvedValue({ id: "delegation-2", }); const { app } = await buildApp({ actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, roleCodes: ["administration"], permissionCodes: ["expense.create", "expense.proxy_submit"], }, travelRequests: [ buildTravelRequest({ applicant_employee_id: OWNER_ID, }), ], }); const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { ownerEmployeeId: OWNER_ID, linkedTravelRequestId: TRAVEL_ID, }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ ownerId: OWNER_ID, linkedTravelRequestId: TRAVEL_ID, }), ); await app.close(); }); });