R58 A1-A6:报销详情页内嵌发票绑定与单据打印

这个提交包含在:
2026-04-11 14:57:15 +08:00
父节点 88c987ae8f
当前提交 ba48e7d1d4
共修改 14 个文件,包含 1667 行新增和 12 行删除
@@ -0,0 +1,253 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const { registerInvoicePoolReadRoutes } = require("../../../src/routes/invoicePool/read");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
const OWNER_ID = "22222222-2222-4222-8222-222222222222";
const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333";
function buildApp({
permissionCodes = ["invoice.view_own"],
employeeId = OWNER_ID,
reimbursement = {
id: REIMBURSEMENT_ID,
owner_id: OWNER_ID,
submitter_id: SUBMITTER_ID,
status: "draft",
},
bindableItems = [],
} = {}) {
const prisma = {
reimbursementForm: {
findUnique: vi.fn(async ({ where }) => {
if (where.id !== REIMBURSEMENT_ID) {
return null;
}
return reimbursement;
}),
},
invoicePool: {
findMany: vi.fn(async ({ where, take }) =>
bindableItems
.filter((item) => {
if (item.owner_employee_id !== where.owner_employee_id) {
return false;
}
if (item.status !== where.status) {
return false;
}
if (item.linked_reimbursement_id !== where.linked_reimbursement_id) {
return false;
}
if (!where.OR?.length) {
return true;
}
const haystacks = [item.invoice_code, item.invoice_number, item.seller_name]
.filter(Boolean)
.map((value) => String(value).toLowerCase());
return where.OR.some((condition) => {
const [field, matcher] = Object.entries(condition)[0];
const needle = String(matcher.contains).toLowerCase();
const value = item[field] ? String(item[field]).toLowerCase() : "";
return value.includes(needle) || haystacks.some((entry) => entry.includes(needle));
});
})
.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())
.slice(0, take),
),
},
};
const app = Fastify();
app.decorate("prisma", prisma);
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = {
id: employeeId,
permissionCodes,
roleCodes: [],
};
});
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: {} },
});
});
return app.register(registerInvoicePoolReadRoutes, { prefix: "/api" }).then(() => ({ app, prisma }));
}
describe("GET /api/invoice-pool/bindable", () => {
it("returns bindable invoices for reimbursement submitter and uses reimbursement owner for filtering", async () => {
const bindableItems = [
{
id: "invoice-2",
owner_employee_id: OWNER_ID,
status: "available",
linked_reimbursement_id: null,
invoice_code: "FP002",
invoice_number: "0002",
invoice_date: new Date("2026-04-08T00:00:00.000Z"),
seller_name: "上海供应商",
total_amount: "88.88",
original_name: "receipt-2.pdf",
created_at: new Date("2026-04-09T09:00:00.000Z"),
},
{
id: "invoice-1",
owner_employee_id: OWNER_ID,
status: "available",
linked_reimbursement_id: null,
invoice_code: "FP001",
invoice_number: "0001",
invoice_date: new Date("2026-04-07T00:00:00.000Z"),
seller_name: "北京供应商",
total_amount: "66.66",
original_name: "receipt-1.pdf",
created_at: new Date("2026-04-08T09:00:00.000Z"),
},
];
const { app, prisma } = await buildApp({
employeeId: SUBMITTER_ID,
bindableItems,
});
const response = await app.inject({
method: "GET",
url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}&limit=1`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items).toHaveLength(1);
expect(response.json().data.items[0]).toMatchObject({
id: "invoice-2",
invoiceCode: "FP002",
sellerName: "上海供应商",
totalAmount: "88.88",
});
expect(prisma.invoicePool.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: 1,
where: expect.objectContaining({
owner_employee_id: OWNER_ID,
status: "available",
linked_reimbursement_id: null,
}),
}),
);
await app.close();
});
it("rejects requests without invoice.view_own permission", async () => {
const { app } = await buildApp({ permissionCodes: [] });
const response = await app.inject({
method: "GET",
url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe("PERM_001");
await app.close();
});
it("rejects reimbursements that are not owned or submitted by current user", async () => {
const { app } = await buildApp({ employeeId: "44444444-4444-4444-8444-444444444444" });
const response = await app.inject({
method: "GET",
url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe("PERM_001");
await app.close();
});
it("rejects reimbursements not in draft or rejected status", async () => {
const { app } = await buildApp({
reimbursement: {
id: REIMBURSEMENT_ID,
owner_id: OWNER_ID,
submitter_id: SUBMITTER_ID,
status: "reviewing",
},
});
const response = await app.inject({
method: "GET",
url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind" });
await app.close();
});
it("applies fuzzy search and returns empty results when nothing matches", async () => {
const { app } = await buildApp({
bindableItems: [
{
id: "invoice-1",
owner_employee_id: OWNER_ID,
status: "available",
linked_reimbursement_id: null,
invoice_code: "A100",
invoice_number: "001",
invoice_date: new Date("2026-04-07T00:00:00.000Z"),
seller_name: "北京供应商",
total_amount: "66.66",
original_name: "receipt-1.pdf",
created_at: new Date("2026-04-08T09:00:00.000Z"),
},
],
});
const response = await app.inject({
method: "GET",
url: `/api/invoice-pool/bindable?reimbursementId=${REIMBURSEMENT_ID}&search=%E4%B8%8A%E6%B5%B7`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items).toEqual([]);
await app.close();
});
});
@@ -0,0 +1,302 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const reimbursementRoutes = require("../../../src/routes/reimbursements");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
const OWNER_ID = "22222222-2222-4222-8222-222222222222";
const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333";
const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444";
const PENDING_INSTANCE_ID = "55555555-5555-4555-8555-555555555555";
function buildReimbursementRecord(overrides = {}) {
return {
id: REIMBURSEMENT_ID,
form_number: "BX20260410001",
submitter_id: SUBMITTER_ID,
owner_id: OWNER_ID,
department_id: DEPARTMENT_ID,
created_at: new Date("2026-04-10T09:00:00.000Z"),
reimbursement_date: new Date("2026-04-10T00:00:00.000Z"),
total_amount: "188.80",
status: "reviewing",
description: "客户拜访报销",
project_id: null,
linked_travel_request_id: null,
department: { id: DEPARTMENT_ID, name: "华东销售部" },
project: null,
travel_request: null,
owner: { id: OWNER_ID, employee_no: "E1001", name: "张三" },
submitter: { id: SUBMITTER_ID, employee_no: "E1002", name: "李四" },
items: [
{
id: "item-1",
expense_type_id: "expense-type-1",
expense_date: new Date("2026-04-09T00:00:00.000Z"),
amount: "188.80",
description: "客户打车",
expense_type: {
id: "expense-type-1",
code: "travel_taxi",
name: "打车费",
category: "travel",
enabled: true,
},
},
],
invoice_pool_items: [
{
id: "invoice-1",
status: "bound",
invoice_code: "FP001",
invoice_number: "0001",
invoice_date: new Date("2026-04-09T00:00:00.000Z"),
seller_name: "上海供应商",
total_amount: "188.80",
original_name: "receipt.pdf",
linked_item_id: "item-1",
},
],
workflow_instances: [
{
id: PENDING_INSTANCE_ID,
current_step_id: "finance_approve",
current_status: "pending",
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务审批" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务审批" },
],
},
},
workflow_tasks: [
{
id: "task-1",
step_name: "财务审批",
assignee_id: "finance-1",
assignee_role: "finance",
},
],
},
],
...overrides,
};
}
async function buildApp({
employeeId = OWNER_ID,
permissionCodes = ["expense.view_own"],
reimbursement = buildReimbursementRecord(),
latestWorkflowInstance = {
id: PENDING_INSTANCE_ID,
current_step_id: "finance_approve",
created_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务审批" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务审批" },
],
},
},
},
workflowHistoryRows = [
{
id: "history-start",
action: "start",
comment: null,
payload: { step_id: "manager_approve" },
created_at: new Date("2026-04-10T10:00:00.000Z"),
actor: { name: "李四" },
workflow_task: { step_name: "主管审批" },
},
{
id: "history-approve",
action: "approve",
comment: "同意",
payload: { from_step_id: "manager_approve", to_step_id: "finance_approve" },
created_at: new Date("2026-04-10T12:00:00.000Z"),
actor: { name: "王经理" },
workflow_task: { step_name: "主管审批" },
},
{
id: "history-skip",
action: "skip",
comment: null,
payload: { step_id: "ceo_approve" },
created_at: new Date("2026-04-10T12:01:00.000Z"),
actor: { name: "系统" },
workflow_task: null,
},
],
} = {}) {
const app = Fastify();
const prisma = {
reimbursementForm: {
findUnique: vi.fn(async ({ where }) => {
if (where.id !== REIMBURSEMENT_ID) {
return null;
}
return reimbursement;
}),
},
workflowInstance: {
findFirst: vi.fn(async () => latestWorkflowInstance),
},
workflowHistory: {
findMany: vi.fn(async () => workflowHistoryRows),
},
entityRiskFlag: {
findMany: vi.fn(async () => []),
},
budgetPool: {
findMany: vi.fn(async () => []),
},
};
app.decorate("prisma", prisma);
app.decorate("config", {
budgetControlMode: "warn",
appTimezone: "Asia/Shanghai",
});
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = {
id: employeeId,
permissionCodes,
roleCodes: [],
department_id: DEPARTMENT_ID,
};
});
app.decorate(
"hasPermission",
(employee, permissionCode) =>
(employee?.permissionCodes ?? []).includes("*") ||
(employee?.permissionCodes ?? []).includes(permissionCode),
);
app.decorate("requirePermission", (permissionCode) => async (request) => {
await app.authenticate(request);
if (!app.hasPermission(request.employee, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("assertEntityPermission", () => {});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(reimbursementRoutes, { prefix: "/api" });
return { app, prisma };
}
describe("GET /api/reimbursements/:id detail enhancements", () => {
it("returns department, submittedAt, expanded invoices and printable approvalHistory", async () => {
const { app } = await buildApp();
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
department: {
id: DEPARTMENT_ID,
name: "华东销售部",
},
createdAt: "2026-04-10T09:00:00.000Z",
submittedAt: "2026-04-10T10:00:00.000Z",
invoices: [
{
id: "invoice-1",
invoiceCode: "FP001",
invoiceNumber: "0001",
invoiceDate: "2026-04-09",
sellerName: "上海供应商",
totalAmount: "188.80",
originalName: "receipt.pdf",
linkedItemId: "item-1",
},
],
approvalHistory: [
{
id: "history-start",
stepName: "主管审批",
action: "start",
actorName: "李四",
comment: null,
createdAt: "2026-04-10T10:00:00.000Z",
},
{
id: "history-approve",
stepName: "主管审批",
action: "approve",
actorName: "王经理",
comment: "同意",
createdAt: "2026-04-10T12:00:00.000Z",
},
],
});
expect(response.json().data.workflowProgress).toMatchObject({
currentStepIndex: 1,
totalSteps: 2,
});
await app.close();
});
it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => {
const reimbursement = buildReimbursementRecord({
status: "draft",
workflow_instances: [],
});
const { app } = await buildApp({
reimbursement,
latestWorkflowInstance: null,
workflowHistoryRows: [],
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.submittedAt).toBe(null);
expect(response.json().data.approvalHistory).toEqual([]);
await app.close();
});
});