feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 539c2cc4dd
当前提交 dc74d9e53f
共修改 23 个文件,包含 4265 行新增和 218 行删除
@@ -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();
});
});
+285 -85
查看文件
@@ -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" }));
});
});