R58 A1-A6:报销详情页内嵌发票绑定与单据打印
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户