707 行
24 KiB
JavaScript
707 行
24 KiB
JavaScript
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";
|
|
|
|
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 },
|
|
],
|
|
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 (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("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("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();
|
|
});
|
|
});
|