feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端
这个提交包含在:
@@ -53,6 +53,14 @@ describe("Permission Constants", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.manage")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants travel.payment.manage only to configured finance and administration roles", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.payment.manage")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.payment.manage")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.payment.manage")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.payment.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.payment.manage")).toBe(false);
|
||||
});
|
||||
|
||||
it("grants ai.assist only to finance manager and admin", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "ai.assist")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "ai.assist")).toBe(false);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const invoicePoolRoutes = require("../../../src/routes/invoicePool");
|
||||
|
||||
function buildQueryRawHandler(state) {
|
||||
return vi.fn(async (strings) => {
|
||||
const sql = Array.isArray(strings) ? strings.join("?") : String(strings);
|
||||
if (sql.includes('FROM "invoice_pool"')) {
|
||||
return state.invoice ? [{
|
||||
id: state.invoice.id,
|
||||
status: state.invoice.status,
|
||||
linked_reimbursement_id: state.invoice.linked_reimbursement_id,
|
||||
travel_order_id: state.invoice.travel_order_id,
|
||||
}] : [];
|
||||
}
|
||||
if (sql.includes('FROM "travel_orders"')) {
|
||||
return state.travelOrder ? [{ id: state.travelOrder.id, status: state.travelOrder.status }] : [];
|
||||
}
|
||||
if (sql.includes('FROM "reimbursement_forms"')) {
|
||||
return state.reimbursement ? [state.reimbursement] : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function buildPrisma({ invoice, travelOrder, reimbursement = null }) {
|
||||
const state = {
|
||||
invoice: { ...invoice },
|
||||
travelOrder: travelOrder ? { ...travelOrder } : null,
|
||||
reimbursement,
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
invoicePool: {
|
||||
findUnique: vi.fn().mockImplementation(async () => state.invoice),
|
||||
update: vi.fn().mockImplementation(async ({ data, select }) => {
|
||||
state.invoice = { ...state.invoice, ...data };
|
||||
if (!select) {
|
||||
return state.invoice;
|
||||
}
|
||||
return {
|
||||
...state.invoice,
|
||||
verification_records: [],
|
||||
};
|
||||
}),
|
||||
},
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "item-1" }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||
},
|
||||
$queryRaw: buildQueryRawHandler(state),
|
||||
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
|
||||
};
|
||||
|
||||
return { prisma, state };
|
||||
}
|
||||
|
||||
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] }) {
|
||||
const app = Fastify();
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
storageDir: "/tmp",
|
||||
appOrigin: "http://localhost:5173",
|
||||
maxUploadSizeBytes: 10 * 1024 * 1024,
|
||||
invoiceArchiveUnusedDays: 30,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = { id: "actor-1", permissionCodes };
|
||||
});
|
||||
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.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(invoicePoolRoutes, { prefix: "/api" });
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("invoicePool travel-order bind routes", () => {
|
||||
it("binds invoice to confirmed travel order", async () => {
|
||||
const { prisma, state } = buildPrisma({
|
||||
invoice: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "available",
|
||||
linked_reimbursement_id: null,
|
||||
linked_item_id: null,
|
||||
travel_order_id: null,
|
||||
total_amount: "500.00",
|
||||
verification_records: [],
|
||||
},
|
||||
travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "confirmed" },
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { travelOrderId: "22222222-2222-4222-8222-222222222222" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.linkedTravelOrderId).toBe("22222222-2222-4222-8222-222222222222");
|
||||
expect(state.invoice.travel_order_id).toBe("22222222-2222-4222-8222-222222222222");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects binding when invoice is already linked elsewhere", async () => {
|
||||
const { prisma } = buildPrisma({
|
||||
invoice: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "bound",
|
||||
linked_reimbursement_id: "reim-1",
|
||||
linked_item_id: null,
|
||||
travel_order_id: null,
|
||||
total_amount: "500.00",
|
||||
verification_records: [],
|
||||
},
|
||||
travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "confirmed" },
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { travelOrderId: "22222222-2222-4222-8222-222222222222" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.code).toBe("VALID_005");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("unbinds invoice from travel order with payment manage permission", async () => {
|
||||
const { prisma, state } = buildPrisma({
|
||||
invoice: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
status: "bound",
|
||||
linked_reimbursement_id: null,
|
||||
linked_item_id: null,
|
||||
travel_order_id: "22222222-2222-4222-8222-222222222222",
|
||||
total_amount: "500.00",
|
||||
verification_records: [],
|
||||
},
|
||||
travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "paid" },
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/unbind",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.linkedTravelOrderId).toBe(null);
|
||||
expect(state.invoice.status).toBe("available");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const travelAuthorizationsRoutes = require("../../../src/routes/travelAuthorizations");
|
||||
|
||||
function buildAuthorizationRecord(overrides = {}) {
|
||||
const now = new Date("2026-03-10T00:00:00.000Z");
|
||||
return {
|
||||
id: "auth-1",
|
||||
source: "corp-card",
|
||||
external_auth_no: "AUTH-001",
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
travel_request_id: "11111111-1111-4111-8111-111111111111",
|
||||
budget_pool_id: "pool-1",
|
||||
legal_entity_id: "legal-1",
|
||||
cost_center_id: "cc-1",
|
||||
authorized_amount: "1000.00",
|
||||
settled_amount: "0.00",
|
||||
reversed_amount: "0.00",
|
||||
currency: "CNY",
|
||||
occurred_at: now,
|
||||
expires_at: null,
|
||||
status: "authorized",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma({ employees = [], travelRequests = [], existing = null, existingLedger = [] } = {}) {
|
||||
const prisma = {
|
||||
employee: {
|
||||
findMany: vi.fn().mockResolvedValue(employees),
|
||||
},
|
||||
travelRequest: {
|
||||
findMany: vi.fn().mockResolvedValue(travelRequests),
|
||||
},
|
||||
cardAuthorization: {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
findMany: vi.fn().mockResolvedValue(existing ? [existing] : []),
|
||||
count: vi.fn().mockResolvedValue(existing ? 1 : 0),
|
||||
upsert: vi.fn().mockImplementation(async ({ create }) => buildAuthorizationRecord({
|
||||
source: create.source,
|
||||
external_auth_no: create.external_auth_no,
|
||||
employee_id: create.employee_id,
|
||||
department_id: create.department_id,
|
||||
travel_request_id: create.travel_request_id,
|
||||
budget_pool_id: create.budget_pool_id,
|
||||
legal_entity_id: create.legal_entity_id,
|
||||
cost_center_id: create.cost_center_id,
|
||||
authorized_amount: create.authorized_amount,
|
||||
settled_amount: create.settled_amount,
|
||||
reversed_amount: create.reversed_amount,
|
||||
currency: create.currency,
|
||||
occurred_at: create.occurred_at,
|
||||
expires_at: create.expires_at,
|
||||
status: create.status,
|
||||
})),
|
||||
},
|
||||
budgetLedgerEntry: {
|
||||
findMany: vi.fn().mockResolvedValue(existingLedger),
|
||||
create: vi.fn().mockResolvedValue({ id: "ledger-1" }),
|
||||
},
|
||||
budgetPool: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "pool-1", used_amount: "0.00" }),
|
||||
update: vi.fn().mockResolvedValue({ id: "pool-1" }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||
},
|
||||
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
|
||||
};
|
||||
|
||||
return prisma;
|
||||
}
|
||||
|
||||
async function buildApp({ prisma, permissionCodes = ["integration.manage", "travel.payment.manage"] }) {
|
||||
const app = Fastify();
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", { travelSyncEnabled: true });
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = { id: "actor-1", permissionCodes };
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => (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.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(travelAuthorizationsRoutes, { prefix: "/api" });
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("travelAuthorizations routes", () => {
|
||||
it("imports authorizations on happy path", async () => {
|
||||
const prisma = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
applicant_employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
budget_pool_id: "pool-1",
|
||||
legal_entity_id: "legal-1",
|
||||
cost_center_id: "cc-1",
|
||||
}],
|
||||
});
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/authorizations/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
authorizations: [{
|
||||
source: "corp-card",
|
||||
externalAuthNo: "AUTH-001",
|
||||
employeeId: "employee-1",
|
||||
travelRequestId: "11111111-1111-4111-8111-111111111111",
|
||||
authorizedAmount: "1000.00",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({ imported: 1, authorizedCount: 1 }));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("treats duplicate import as idempotent", async () => {
|
||||
const existing = buildAuthorizationRecord();
|
||||
const prisma = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
applicant_employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
budget_pool_id: "pool-1",
|
||||
legal_entity_id: "legal-1",
|
||||
cost_center_id: "cc-1",
|
||||
}],
|
||||
existing,
|
||||
existingLedger: [{
|
||||
id: "ledger-1",
|
||||
budget_pool_id: "pool-1",
|
||||
entity_type: "card_authorization",
|
||||
entity_id: existing.id,
|
||||
kind: "authorize",
|
||||
amount: "1000.00",
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
}],
|
||||
});
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/authorizations/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
authorizations: [{
|
||||
source: "corp-card",
|
||||
externalAuthNo: "AUTH-001",
|
||||
employeeId: "employee-1",
|
||||
travelRequestId: "11111111-1111-4111-8111-111111111111",
|
||||
authorizedAmount: "1000.00",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({ imported: 1, authorizedCount: 0 }));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("skips rows when request missing", async () => {
|
||||
const prisma = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests: [],
|
||||
});
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/authorizations/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
authorizations: [{
|
||||
source: "corp-card",
|
||||
externalAuthNo: "AUTH-404",
|
||||
employeeId: "employee-1",
|
||||
travelRequestId: "11111111-1111-4111-8111-111111111111",
|
||||
authorizedAmount: "1000.00",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.skipped).toEqual([
|
||||
expect.objectContaining({ reason: "travel_request_not_found" }),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("skips rows when request employee mismatches", async () => {
|
||||
const prisma = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
applicant_employee_id: "employee-9",
|
||||
department_id: "dept-1",
|
||||
budget_pool_id: "pool-1",
|
||||
legal_entity_id: "legal-1",
|
||||
cost_center_id: "cc-1",
|
||||
}],
|
||||
});
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/authorizations/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
authorizations: [{
|
||||
source: "corp-card",
|
||||
externalAuthNo: "AUTH-002",
|
||||
employeeId: "employee-1",
|
||||
travelRequestId: "11111111-1111-4111-8111-111111111111",
|
||||
authorizedAmount: "1000.00",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.skipped).toEqual([
|
||||
expect.objectContaining({ reason: "travel_request_employee_mismatch" }),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,83 @@ const Fastify = require("fastify");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const travelOrdersRoutes = require("../../../src/routes/travelOrders");
|
||||
|
||||
function buildPrisma({ employees = [], travelRequests = [], upsertImpl } = {}) {
|
||||
function buildQueryRawHandler({ currentOrder, reimbursement = null }) {
|
||||
return vi.fn(async (strings) => {
|
||||
const sql = Array.isArray(strings) ? strings.join("?") : String(strings);
|
||||
if (sql.includes('FROM "travel_orders"')) {
|
||||
return currentOrder ? [currentOrder] : [];
|
||||
}
|
||||
if (sql.includes('FROM "reimbursement_forms"')) {
|
||||
return reimbursement ? [reimbursement] : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function buildTravelOrderRecord(overrides = {}) {
|
||||
const now = new Date("2026-03-10T10:00:00.000Z");
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
source: "ctrip",
|
||||
external_order_no: "EXT-001",
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
linked_reimbursement_id: null,
|
||||
travel_request_id: "22222222-2222-4222-8222-222222222222",
|
||||
amount: "1888.00",
|
||||
currency: "CNY",
|
||||
status: "confirmed",
|
||||
payment_voucher_no: null,
|
||||
payment_date: null,
|
||||
payment_channel: null,
|
||||
paid_at: null,
|
||||
paid_by: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTravelOrderWithRequest(order) {
|
||||
return {
|
||||
...order,
|
||||
travel_request: {
|
||||
id: order.travel_request_id,
|
||||
request_no: "CL-20260310-0001",
|
||||
department_id: order.department_id,
|
||||
applicant_employee_id: "employee-1",
|
||||
submitter_id: null,
|
||||
legal_entity: { code: "HY" },
|
||||
cost_center: { code: "CC001" },
|
||||
budget_pool: { code: "POOL-1" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma({
|
||||
employees = [],
|
||||
travelRequests = [],
|
||||
existingOrders = [],
|
||||
currentOrder = null,
|
||||
matchedTransaction = { id: "txn-1" },
|
||||
featureFlagRecord = {
|
||||
id: "flag-1",
|
||||
flag_key: "erp_sync",
|
||||
override_mode: "inherit",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
},
|
||||
existingOutbox = null,
|
||||
} = {}) {
|
||||
const state = {
|
||||
currentOrder: currentOrder ? { ...currentOrder } : null,
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
employee: {
|
||||
findMany: vi.fn().mockResolvedValue(employees),
|
||||
},
|
||||
@@ -12,40 +87,79 @@ function buildPrisma({ employees = [], travelRequests = [], upsertImpl } = {}) {
|
||||
findMany: vi.fn().mockResolvedValue(travelRequests),
|
||||
},
|
||||
travelOrder: {
|
||||
upsert: vi.fn().mockImplementation(upsertImpl ?? (async ({ create }) => ({
|
||||
id: "order-1",
|
||||
findMany: vi.fn().mockResolvedValue(existingOrders),
|
||||
upsert: vi.fn().mockImplementation(async ({ create, update }) => ({
|
||||
id: state.currentOrder?.id ?? "order-1",
|
||||
source: create.source,
|
||||
external_order_no: create.external_order_no,
|
||||
employee_id: create.employee_id,
|
||||
department_id: create.department_id,
|
||||
linked_reimbursement_id: create.linked_reimbursement_id,
|
||||
travel_request_id: create.travel_request_id,
|
||||
travel_request_id: update.travel_request_id === undefined ? state.currentOrder?.travel_request_id ?? create.travel_request_id : update.travel_request_id,
|
||||
occurred_at: create.occurred_at,
|
||||
amount: create.amount,
|
||||
currency: create.currency,
|
||||
status: create.status,
|
||||
status: update.status,
|
||||
payment_voucher_no: state.currentOrder?.payment_voucher_no ?? null,
|
||||
payment_date: state.currentOrder?.payment_date ?? null,
|
||||
payment_channel: state.currentOrder?.payment_channel ?? null,
|
||||
paid_at: state.currentOrder?.paid_at ?? null,
|
||||
paid_by: state.currentOrder?.paid_by ?? null,
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
}))),
|
||||
})),
|
||||
update: vi.fn().mockImplementation(async ({ data }) => {
|
||||
state.currentOrder = {
|
||||
...state.currentOrder,
|
||||
...data,
|
||||
updated_at: new Date("2026-03-10T11:00:00.000Z"),
|
||||
};
|
||||
return state.currentOrder;
|
||||
}),
|
||||
findUnique: vi.fn().mockImplementation(async () => {
|
||||
if (!state.currentOrder) {
|
||||
return null;
|
||||
}
|
||||
return buildTravelOrderWithRequest(state.currentOrder);
|
||||
}),
|
||||
},
|
||||
cardTransaction: {
|
||||
findFirst: vi.fn().mockResolvedValue(matchedTransaction),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue(featureFlagRecord),
|
||||
},
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue(existingOutbox),
|
||||
create: vi.fn().mockResolvedValue({ id: "outbox-1", status: "pending" }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||
},
|
||||
$queryRaw: buildQueryRawHandler({ currentOrder: state.currentOrder }),
|
||||
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
|
||||
};
|
||||
|
||||
return { prisma, state };
|
||||
}
|
||||
|
||||
async function buildApp({ prisma }) {
|
||||
async function buildApp({ prisma, permissionCodes = ["integration.manage", "travel.payment.manage"] , config = {} }) {
|
||||
const app = Fastify();
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", { travelSyncEnabled: true });
|
||||
app.decorate("config", {
|
||||
travelSyncEnabled: true,
|
||||
erpSyncEnabled: true,
|
||||
integrationOutboxMaxAttempts: 5,
|
||||
...config,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = { id: "actor-1", permissionCodes: ["integration.manage"] };
|
||||
request.employee = { id: "actor-1", department_id: "dept-1", permissionCodes };
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode));
|
||||
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)) {
|
||||
@@ -76,11 +190,11 @@ async function buildApp({ prisma }) {
|
||||
|
||||
describe("travelOrders routes", () => {
|
||||
it("imports rows with legal travelRequestId linkage", async () => {
|
||||
const prisma = buildPrisma({
|
||||
const { prisma } = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests: [{ id: "11111111-1111-4111-8111-111111111111", applicant_employee_id: "employee-1" }],
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -106,85 +220,24 @@ describe("travelOrders routes", () => {
|
||||
linkedTravelRequestCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(response.json().data.items[0].travelRequestId).toBe("11111111-1111-4111-8111-111111111111");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("skips rows when travel request employee mismatches", async () => {
|
||||
const prisma = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests: [{ id: "11111111-1111-4111-8111-111111111111", applicant_employee_id: "employee-2" }],
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/orders/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
orders: [{ source: "ctrip", externalOrderNo: "EXT-002", employeeId: "employee-1", travelRequestId: "11111111-1111-4111-8111-111111111111" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.imported).toBe(0);
|
||||
expect(response.json().data.skipped).toEqual([
|
||||
expect.objectContaining({ reason: "travel_request_employee_mismatch" }),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("skips rows when travel request does not exist", async () => {
|
||||
const prisma = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests: [],
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/orders/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
orders: [{ source: "ctrip", externalOrderNo: "EXT-003", employeeId: "employee-1", travelRequestId: "11111111-1111-4111-8111-111111111111" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.imported).toBe(0);
|
||||
expect(response.json().data.skipped).toEqual([
|
||||
expect.objectContaining({ reason: "travel_request_not_found" }),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("preserves existing travel linkage when new row omits travelRequestId", async () => {
|
||||
const prisma = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests: [],
|
||||
upsertImpl: async ({ create, update }) => {
|
||||
expect(update.travel_request_id).toBeUndefined();
|
||||
return {
|
||||
id: "order-2",
|
||||
source: create.source,
|
||||
external_order_no: create.external_order_no,
|
||||
employee_id: create.employee_id,
|
||||
department_id: create.department_id,
|
||||
linked_reimbursement_id: create.linked_reimbursement_id,
|
||||
travel_request_id: "existing-travel-request",
|
||||
occurred_at: create.occurred_at,
|
||||
amount: create.amount,
|
||||
currency: create.currency,
|
||||
status: "imported",
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
};
|
||||
},
|
||||
const existingOrder = buildTravelOrderRecord({
|
||||
id: "order-2",
|
||||
source: "ctrip",
|
||||
external_order_no: "EXT-004",
|
||||
status: "paid",
|
||||
travel_request_id: "existing-travel-request",
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
const { prisma } = buildPrisma({
|
||||
employees: [{ id: "employee-1", department_id: "dept-1" }],
|
||||
existingOrders: [existingOrder],
|
||||
currentOrder: existingOrder,
|
||||
});
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -197,6 +250,153 @@ describe("travelOrders routes", () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0].travelRequestId).toBe("existing-travel-request");
|
||||
expect(response.json().data.items[0].status).toBe("paid");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("marks confirmed order as paid and enqueues ERP journal", async () => {
|
||||
const { prisma, state } = buildPrisma({
|
||||
currentOrder: buildTravelOrderRecord(),
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
paymentVoucherNo: "BANK-001",
|
||||
paymentDate: "2026-03-10",
|
||||
paymentChannel: "bank_transfer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "paid",
|
||||
paymentVoucherNo: "BANK-001",
|
||||
paymentDate: "2026-03-10",
|
||||
paymentChannel: "bank_transfer",
|
||||
erpJournal: expect.objectContaining({ enqueued: true, outboxId: "outbox-1" }),
|
||||
}),
|
||||
);
|
||||
expect(state.currentOrder.status).toBe("paid");
|
||||
expect(prisma.integrationOutbox.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns idempotent success for already paid order with same payload", async () => {
|
||||
const currentOrder = buildTravelOrderRecord({
|
||||
status: "paid",
|
||||
payment_voucher_no: "BANK-001",
|
||||
payment_date: new Date("2026-03-10T00:00:00.000Z"),
|
||||
payment_channel: "bank_transfer",
|
||||
paid_at: new Date("2026-03-10T12:00:00.000Z"),
|
||||
paid_by: "actor-1",
|
||||
});
|
||||
const { prisma } = buildPrisma({
|
||||
currentOrder,
|
||||
existingOutbox: { id: "outbox-existing", status: "pending", payload: {} },
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
paymentVoucherNo: "BANK-001",
|
||||
paymentDate: "2026-03-10",
|
||||
paymentChannel: "bank_transfer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.erpJournal).toEqual(
|
||||
expect.objectContaining({ enqueued: true, outboxId: "outbox-existing" }),
|
||||
);
|
||||
expect(prisma.travelOrder.update).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects conflicting paid payload", async () => {
|
||||
const { prisma } = buildPrisma({
|
||||
currentOrder: buildTravelOrderRecord({
|
||||
status: "paid",
|
||||
payment_voucher_no: "BANK-001",
|
||||
payment_date: new Date("2026-03-10T00:00:00.000Z"),
|
||||
payment_channel: "bank_transfer",
|
||||
}),
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
paymentVoucherNo: "BANK-002",
|
||||
paymentDate: "2026-03-10",
|
||||
paymentChannel: "bank_transfer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual({ payment: "already_paid_conflict" });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns 409 when no settled transaction matches the order", async () => {
|
||||
const { prisma } = buildPrisma({
|
||||
currentOrder: buildTravelOrderRecord(),
|
||||
matchedTransaction: null,
|
||||
});
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
paymentVoucherNo: null,
|
||||
paymentDate: null,
|
||||
paymentChannel: "manual",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual({ payment: "missing_settled_transaction" });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("skips ERP enqueue when gate is off", async () => {
|
||||
const { prisma } = buildPrisma({
|
||||
currentOrder: buildTravelOrderRecord(),
|
||||
});
|
||||
const app = await buildApp({ prisma, config: { erpSyncEnabled: false } });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
paymentVoucherNo: "BANK-001",
|
||||
paymentDate: "2026-03-10",
|
||||
paymentChannel: "bank_transfer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.erpJournal).toEqual(
|
||||
expect.objectContaining({ enqueued: false, outboxId: null }),
|
||||
);
|
||||
expect(prisma.integrationOutbox.create).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const travelRequestsRoutes = require("../../../src/routes/travelRequests");
|
||||
|
||||
function buildTravelRequestRecord() {
|
||||
const now = new Date("2026-03-10T00:00:00.000Z");
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
request_no: "CL-20260310-0001",
|
||||
applicant_employee_id: "employee-1",
|
||||
submitter_id: null,
|
||||
department_id: "dept-1",
|
||||
legal_entity_id: "legal-1",
|
||||
cost_center_id: "cc-1",
|
||||
budget_pool_id: "pool-1",
|
||||
purpose: "上海客户拜访",
|
||||
trip_type: "domestic",
|
||||
start_date: new Date("2026-03-15T00:00:00.000Z"),
|
||||
end_date: new Date("2026-03-17T00:00:00.000Z"),
|
||||
estimated_amount: "1280.00",
|
||||
status: "approved",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
applicant: { id: "employee-1", employee_no: "E001", name: "张三" },
|
||||
submitter: null,
|
||||
department: { id: "dept-1", name: "销售部" },
|
||||
legal_entity: { id: "legal-1", code: "HY", name: "海音达" },
|
||||
cost_center: { id: "cc-1", code: "CC001", name: "销售中心", legal_entity_id: "legal-1" },
|
||||
budget_pool: { id: "pool-1", code: "POOL-1", name: "差旅池" },
|
||||
segments: [
|
||||
{
|
||||
id: "seg-1",
|
||||
sort_order: 1,
|
||||
segment_role: "outbound",
|
||||
transport_type: "flight",
|
||||
from_city: "深圳",
|
||||
to_city: "上海",
|
||||
depart_at: new Date("2026-03-15T01:00:00.000Z"),
|
||||
arrive_at: new Date("2026-03-15T03:30:00.000Z"),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
],
|
||||
travel_orders: [
|
||||
{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
source: "ctrip",
|
||||
external_order_no: "EXT-001",
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
linked_reimbursement_id: null,
|
||||
travel_request_id: "11111111-1111-4111-8111-111111111111",
|
||||
occurred_at: new Date("2026-03-12T00:00:00.000Z"),
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
status: "confirmed",
|
||||
payment_voucher_no: null,
|
||||
payment_date: null,
|
||||
payment_channel: null,
|
||||
paid_at: null,
|
||||
paid_by: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
],
|
||||
card_authorizations: [
|
||||
{
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
source: "corp-card",
|
||||
external_auth_no: "AUTH-001",
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
travel_request_id: "11111111-1111-4111-8111-111111111111",
|
||||
budget_pool_id: "pool-1",
|
||||
legal_entity_id: "legal-1",
|
||||
cost_center_id: "cc-1",
|
||||
authorized_amount: "500.00",
|
||||
settled_amount: "0.00",
|
||||
reversed_amount: "0.00",
|
||||
currency: "CNY",
|
||||
occurred_at: now,
|
||||
expires_at: null,
|
||||
status: "authorized",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
],
|
||||
card_transactions: [
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
source: "corp-card",
|
||||
external_txn_id: "TXN-001",
|
||||
card_authorization_id: "33333333-3333-4333-8333-333333333333",
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
travel_request_id: "11111111-1111-4111-8111-111111111111",
|
||||
travel_order_id: "22222222-2222-4222-8222-222222222222",
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
txn_type: "charge",
|
||||
status: "settled",
|
||||
occurred_at: now,
|
||||
freeze_suggested_at: null,
|
||||
freeze_suggested_by: null,
|
||||
freeze_reason_code: null,
|
||||
freeze_reason_note: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
],
|
||||
workflow_instances: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma({ detail = buildTravelRequestRecord() } = {}) {
|
||||
return {
|
||||
travelRequest: {
|
||||
findMany: vi.fn().mockResolvedValue([detail]),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
findUnique: vi.fn().mockResolvedValue(detail),
|
||||
},
|
||||
travelOrder: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...detail.travel_orders[0],
|
||||
travel_request: {
|
||||
id: detail.id,
|
||||
request_no: detail.request_no,
|
||||
department_id: detail.department_id,
|
||||
applicant_employee_id: detail.applicant_employee_id,
|
||||
submitter_id: detail.submitter_id,
|
||||
legal_entity: { code: detail.legal_entity.code },
|
||||
cost_center: { code: detail.cost_center.code },
|
||||
budget_pool: { code: detail.budget_pool.code },
|
||||
},
|
||||
}),
|
||||
},
|
||||
invoicePool: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "invoice-1",
|
||||
status: "bound",
|
||||
original_name: "flight.pdf",
|
||||
total_amount: "500.00",
|
||||
invoice_code: "INV001",
|
||||
invoice_number: "NO001",
|
||||
invoice_date: new Date("2026-03-16T00:00:00.000Z"),
|
||||
linked_reimbursement_id: null,
|
||||
travel_order_id: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
]),
|
||||
},
|
||||
budgetLedgerEntry: {
|
||||
findMany: vi.fn().mockImplementation(async ({ where }) => {
|
||||
if (where?.entity_type === "travel_request") {
|
||||
return [
|
||||
{
|
||||
id: "ledger-r1",
|
||||
budget_pool_id: "pool-1",
|
||||
kind: "reserve",
|
||||
amount: "500.00",
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "ledger-r2",
|
||||
budget_pool_id: "pool-1",
|
||||
kind: "confirm",
|
||||
amount: "500.00",
|
||||
created_at: new Date("2026-03-10T01:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "ledger-c1",
|
||||
budget_pool_id: "pool-1",
|
||||
entity_type: "card_authorization",
|
||||
entity_id: "33333333-3333-4333-8333-333333333333",
|
||||
kind: "authorize",
|
||||
amount: "500.00",
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "ledger-c2",
|
||||
budget_pool_id: "pool-1",
|
||||
entity_type: "card_transaction",
|
||||
entity_id: "44444444-4444-4444-8444-444444444444",
|
||||
kind: "settle",
|
||||
amount: "500.00",
|
||||
created_at: new Date("2026-03-10T02:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
}),
|
||||
},
|
||||
budgetPool: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "pool-1", code: "POOL-1", name: "差旅池" }]),
|
||||
},
|
||||
integrationBinding: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
integrationOutbox: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({ employee, prisma }) {
|
||||
const app = Fastify();
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", { appTimezone: "Asia/Shanghai" });
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = employee;
|
||||
});
|
||||
app.decorate("hasPermission", (actor, permissionCode) => {
|
||||
return (actor?.permissionCodes ?? []).includes("*") || (actor?.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.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(travelRequestsRoutes, { prefix: "/api" });
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("travelRequests payment-track route", () => {
|
||||
it("allows owner to view payment track in read-only mode", async () => {
|
||||
const detail = buildTravelRequestRecord();
|
||||
const app = await buildApp({
|
||||
employee: { id: "employee-1", department_id: "dept-1", permissionCodes: ["travel.view_own"] },
|
||||
prisma: buildPrisma({ detail }),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.permissions.canManagePayments).toBe(false);
|
||||
expect(response.json().data.travelOrders[0].actions.canMarkPaid).toBe(false);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows finance manager to view payment track with manage actions", async () => {
|
||||
const detail = buildTravelRequestRecord();
|
||||
const app = await buildApp({
|
||||
employee: { id: "finance-1", department_id: "dept-9", permissionCodes: ["travel.view_all", "travel.payment.manage"] },
|
||||
prisma: buildPrisma({ detail }),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.permissions.canManagePayments).toBe(true);
|
||||
expect(response.json().data.travelOrders[0].actions).toEqual(
|
||||
expect.objectContaining({ canMarkPaid: true, canBindInvoice: true }),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("blocks outsider without view_all permission", async () => {
|
||||
const detail = buildTravelRequestRecord();
|
||||
const app = await buildApp({
|
||||
employee: { id: "employee-9", department_id: "dept-9", permissionCodes: ["travel.view_own"] },
|
||||
prisma: buildPrisma({ detail }),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/travel-requests/11111111-1111-4111-8111-111111111111/payment-track",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error.code).toBe("PERM_001");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const travelTransactionsRoutes = require("../../../src/routes/travelTransactions");
|
||||
|
||||
function buildOrder(overrides = {}) {
|
||||
return {
|
||||
id: "order-1",
|
||||
external_order_no: "EXT-001",
|
||||
employee_id: "employee-1",
|
||||
travel_request_id: "travel-1",
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
status: "imported",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma({
|
||||
employees = [{ id: "employee-1", department_id: "dept-1" }],
|
||||
travelRequests = [{ id: "travel-1", applicant_employee_id: "employee-1", budget_pool_id: "pool-1" }],
|
||||
authorizations = [],
|
||||
travelOrders = [buildOrder()],
|
||||
existing = null,
|
||||
existingLedger = [],
|
||||
budgetPool = { id: "pool-1", used_amount: "1000.00" },
|
||||
} = {}) {
|
||||
const state = {
|
||||
order: travelOrders[0] ? { ...travelOrders[0] } : null,
|
||||
transaction: null,
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
employee: {
|
||||
findMany: vi.fn().mockResolvedValue(employees),
|
||||
},
|
||||
travelRequest: {
|
||||
findMany: vi.fn().mockResolvedValue(travelRequests),
|
||||
},
|
||||
cardAuthorization: {
|
||||
findMany: vi.fn().mockResolvedValue(authorizations),
|
||||
findUnique: vi.fn().mockImplementation(async () => null),
|
||||
update: vi.fn().mockResolvedValue({ id: "auth-1" }),
|
||||
},
|
||||
travelOrder: {
|
||||
findMany: vi.fn().mockResolvedValue(travelOrders),
|
||||
findUnique: vi.fn().mockImplementation(async () => state.order),
|
||||
update: vi.fn().mockImplementation(async ({ data }) => {
|
||||
state.order = { ...state.order, ...data };
|
||||
return state.order;
|
||||
}),
|
||||
},
|
||||
cardTransaction: {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
upsert: vi.fn().mockImplementation(async ({ create, update }) => {
|
||||
state.transaction = {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
source: create.source,
|
||||
external_txn_id: create.external_txn_id,
|
||||
card_authorization_id: update.card_authorization_id ?? create.card_authorization_id ?? null,
|
||||
employee_id: create.employee_id,
|
||||
department_id: create.department_id,
|
||||
travel_request_id: create.travel_request_id,
|
||||
travel_order_id: update.travel_order_id ?? create.travel_order_id ?? null,
|
||||
amount: create.amount,
|
||||
currency: create.currency,
|
||||
txn_type: create.txn_type,
|
||||
status: create.status,
|
||||
occurred_at: create.occurred_at,
|
||||
freeze_suggested_at: null,
|
||||
freeze_suggested_by: null,
|
||||
freeze_reason_code: null,
|
||||
freeze_reason_note: null,
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
};
|
||||
return state.transaction;
|
||||
}),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update: vi.fn().mockImplementation(async ({ data }) => ({
|
||||
...(state.transaction ?? {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
source: "corp-card",
|
||||
external_txn_id: "TXN-1",
|
||||
card_authorization_id: null,
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
travel_request_id: "travel-1",
|
||||
travel_order_id: "order-1",
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
txn_type: "charge",
|
||||
status: "settled",
|
||||
occurred_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
}),
|
||||
...data,
|
||||
travel_order: state.order,
|
||||
})),
|
||||
},
|
||||
budgetLedgerEntry: {
|
||||
findMany: vi.fn().mockResolvedValue(existingLedger),
|
||||
create: vi.fn().mockResolvedValue({ id: "ledger-1" }),
|
||||
},
|
||||
budgetPool: {
|
||||
findUnique: vi.fn().mockResolvedValue(budgetPool),
|
||||
update: vi.fn().mockResolvedValue({ id: "pool-1" }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
|
||||
},
|
||||
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
|
||||
};
|
||||
|
||||
prisma.cardTransaction.findUnique.mockImplementation(async ({ where }) => {
|
||||
if (where?.id) {
|
||||
return {
|
||||
...(state.transaction ?? {
|
||||
id: where.id,
|
||||
source: "corp-card",
|
||||
external_txn_id: "TXN-1",
|
||||
card_authorization_id: null,
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
travel_request_id: "travel-1",
|
||||
travel_order_id: state.order?.id ?? null,
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
txn_type: "charge",
|
||||
status: "settled",
|
||||
occurred_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
freeze_suggested_at: null,
|
||||
freeze_suggested_by: null,
|
||||
freeze_reason_code: null,
|
||||
freeze_reason_note: null,
|
||||
created_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T00:00:00.000Z"),
|
||||
}),
|
||||
travel_order: state.order,
|
||||
};
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
|
||||
return { prisma, state };
|
||||
}
|
||||
|
||||
async function buildApp({ prisma, permissionCodes = ["integration.manage", "travel.payment.manage"] }) {
|
||||
const app = Fastify();
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", { travelSyncEnabled: true });
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = { id: "actor-1", permissionCodes };
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => (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.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(travelTransactionsRoutes, { prefix: "/api" });
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("travelTransactions routes", () => {
|
||||
it("confirms travel order when settled charge exactly matches", async () => {
|
||||
const { prisma, state } = buildPrisma();
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/transactions/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
transactions: [{
|
||||
source: "corp-card",
|
||||
externalTxnId: "TXN-001",
|
||||
employeeId: "employee-1",
|
||||
travelRequestId: "travel-1",
|
||||
travelOrderId: "order-1",
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
txnType: "charge",
|
||||
status: "settled",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({ confirmedCount: 1, imported: 1 }));
|
||||
expect(state.order.status).toBe("confirmed");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("writes reverse ledger for refund transactions", async () => {
|
||||
const { prisma } = buildPrisma({
|
||||
travelOrders: [],
|
||||
});
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/transactions/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
transactions: [{
|
||||
source: "corp-card",
|
||||
externalTxnId: "TXN-REFUND-1",
|
||||
employeeId: "employee-1",
|
||||
travelRequestId: "travel-1",
|
||||
amount: "120.00",
|
||||
currency: "CNY",
|
||||
txnType: "refund",
|
||||
status: "settled",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({ reversedCount: 1, imported: 1 }));
|
||||
expect(prisma.budgetPool.update).toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("skips rows when referenced authorization is missing", async () => {
|
||||
const { prisma } = buildPrisma();
|
||||
const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/transactions/import",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
transactions: [{
|
||||
source: "corp-card",
|
||||
externalTxnId: "TXN-MISS-AUTH",
|
||||
employeeId: "employee-1",
|
||||
travelRequestId: "travel-1",
|
||||
cardAuthorizationId: "missing-auth",
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
txnType: "charge",
|
||||
status: "settled",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.skipped).toEqual([
|
||||
expect.objectContaining({ reason: "card_authorization_not_found" }),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("stores freeze suggestion metadata", async () => {
|
||||
const { prisma } = buildPrisma();
|
||||
const app = await buildApp({ prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/travel/transactions/33333333-3333-4333-8333-333333333333/freeze-suggestion",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
reasonCode: "manual_review",
|
||||
note: "请人工复核",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(
|
||||
expect.objectContaining({
|
||||
freezeReasonCode: "manual_review",
|
||||
freezeReasonNote: "请人工复核",
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const {
|
||||
buildTravelOrderPaymentJournalPayload,
|
||||
buildTravelOrderPaymentJournalDedupeKey,
|
||||
buildTravelOrderPaymentJournalPreview,
|
||||
} = require("../../../src/services/integrations/erpJournal");
|
||||
|
||||
describe("erpJournal travel helpers", () => {
|
||||
it("builds payment journal payload with travel dimensions", () => {
|
||||
const payload = buildTravelOrderPaymentJournalPayload({
|
||||
travelOrder: {
|
||||
id: "order-1",
|
||||
source: "ctrip",
|
||||
external_order_no: "EXT-001",
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
travel_request_id: "travel-1",
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
payment_voucher_no: "BANK-001",
|
||||
payment_date: new Date("2026-03-10T00:00:00.000Z"),
|
||||
payment_channel: "bank_transfer",
|
||||
paid_at: new Date("2026-03-10T09:00:00.000Z"),
|
||||
created_at: new Date("2026-03-10T08:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T09:00:00.000Z"),
|
||||
travel_request: {
|
||||
id: "travel-1",
|
||||
request_no: "CL-20260310-0001",
|
||||
applicant_employee_id: "employee-1",
|
||||
submitter_id: null,
|
||||
legal_entity: { code: "HY" },
|
||||
cost_center: { code: "CC001" },
|
||||
budget_pool: { code: "POOL-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
journalType: "travel_order_payment",
|
||||
sourceEventName: "travel_order.paid",
|
||||
entityType: "travel_order",
|
||||
entityId: "order-1",
|
||||
documentNo: "EXT-001",
|
||||
paymentVoucherNo: "BANK-001",
|
||||
paymentDate: "2026-03-10",
|
||||
paymentChannel: "bank_transfer",
|
||||
dimensions: expect.objectContaining({
|
||||
legalEntityCode: "HY",
|
||||
costCenterCode: "CC001",
|
||||
budgetPoolCode: "POOL-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds stable travel-order dedupe key", () => {
|
||||
expect(buildTravelOrderPaymentJournalDedupeKey("order-1")).toBe("travel_order:order-1:paid");
|
||||
});
|
||||
|
||||
it("builds preview with current binding and outbox state", async () => {
|
||||
const prisma = {
|
||||
integrationBinding: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "binding-1",
|
||||
provider: "erp",
|
||||
entity_type: "travel_order_payment_journal",
|
||||
local_id: "order-1",
|
||||
external_id: "erp-1",
|
||||
external_code: "EXT-001",
|
||||
last_synced_at: new Date("2026-03-10T10:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
integrationOutbox: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "outbox-1",
|
||||
event_type: "erp.journal.export",
|
||||
status: "succeeded",
|
||||
last_error: null,
|
||||
processed_at: new Date("2026-03-10T10:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T10:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const preview = await buildTravelOrderPaymentJournalPreview(prisma, {
|
||||
id: "order-1",
|
||||
source: "ctrip",
|
||||
external_order_no: "EXT-001",
|
||||
employee_id: "employee-1",
|
||||
department_id: "dept-1",
|
||||
linked_reimbursement_id: null,
|
||||
travel_request_id: "travel-1",
|
||||
amount: "500.00",
|
||||
currency: "CNY",
|
||||
payment_voucher_no: "BANK-001",
|
||||
payment_date: new Date("2026-03-10T00:00:00.000Z"),
|
||||
payment_channel: "bank_transfer",
|
||||
paid_at: new Date("2026-03-10T09:00:00.000Z"),
|
||||
created_at: new Date("2026-03-10T08:00:00.000Z"),
|
||||
updated_at: new Date("2026-03-10T09:00:00.000Z"),
|
||||
travel_request: {
|
||||
id: "travel-1",
|
||||
request_no: "CL-20260310-0001",
|
||||
applicant_employee_id: "employee-1",
|
||||
submitter_id: null,
|
||||
legal_entity: { code: "HY" },
|
||||
cost_center: { code: "CC001" },
|
||||
budget_pool: { code: "POOL-1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(preview.exportState).toEqual(
|
||||
expect.objectContaining({
|
||||
externalId: "erp-1",
|
||||
lastOutboxId: "outbox-1",
|
||||
lastOutboxStatus: "succeeded",
|
||||
}),
|
||||
);
|
||||
expect(preview.binding).toEqual(expect.objectContaining({ localId: "order-1" }));
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户