R58 A1-A6:报销详情页内嵌发票绑定与单据打印
这个提交包含在:
@@ -5,6 +5,7 @@ const { sendData } = require("../../utils/reply");
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { signPayload, verifyToken } = require("../../utils/signedUrl");
|
||||
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
|
||||
const { toMoneyString } = require("../../utils/workflow");
|
||||
const {
|
||||
mapInvoiceSummary,
|
||||
normalizeInvoiceResponse,
|
||||
@@ -20,6 +21,11 @@ const invoiceListQuerySchema = z.object({
|
||||
status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(),
|
||||
ownerScope: z.enum(["own", "all"]).optional(),
|
||||
});
|
||||
const bindableInvoiceQuerySchema = z.object({
|
||||
reimbursementId: z.string().uuid(),
|
||||
search: z.string().trim().max(100).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
function sanitizeFilename(fileName) {
|
||||
if (!fileName) return "file";
|
||||
@@ -45,6 +51,24 @@ function parseInvoiceListQuery(query) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseBindableInvoiceQuery(query) {
|
||||
const parsed = bindableInvoiceQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reimbursementId: parsed.data.reimbursementId,
|
||||
search: parsed.data.search?.trim() || null,
|
||||
limit: parsed.data.limit ?? 20,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureInvoiceReadableByUser(request, invoice) {
|
||||
const roleCodes = request.employee.roleCodes;
|
||||
const permissionCodes = request.employee.permissionCodes ?? [];
|
||||
@@ -102,6 +126,99 @@ async function hasPendingTaskAccess(fastify, request, linkedReimbursementId) {
|
||||
}
|
||||
|
||||
function registerInvoicePoolReadRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/invoice-pool/bindable",
|
||||
{
|
||||
schema: { tags: ["Invoice Pool"], summary: "List Bindable Invoices For Reimbursement" },
|
||||
preHandler: fastify.requirePermission("invoice.view_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseBindableInvoiceQuery(request.query);
|
||||
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: query.reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reimbursement) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const isOwn =
|
||||
reimbursement.owner_id === request.employee.id ||
|
||||
reimbursement.submitter_id === request.employee.id;
|
||||
if (!isOwn) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "invalid_status_for_bind" },
|
||||
});
|
||||
}
|
||||
|
||||
const where = {
|
||||
owner_employee_id: reimbursement.owner_id,
|
||||
status: "available",
|
||||
linked_reimbursement_id: null,
|
||||
...(query.search
|
||||
? {
|
||||
OR: [
|
||||
{ invoice_code: { contains: query.search, mode: "insensitive" } },
|
||||
{ invoice_number: { contains: query.search, mode: "insensitive" } },
|
||||
{ seller_name: { contains: query.search, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const items = await fastify.prisma.invoicePool.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: query.limit,
|
||||
select: {
|
||||
id: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
invoice_date: true,
|
||||
seller_name: true,
|
||||
total_amount: true,
|
||||
original_name: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
invoiceCode: item.invoice_code ?? null,
|
||||
invoiceNumber: item.invoice_number ?? null,
|
||||
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
sellerName: item.seller_name ?? null,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
originalName: item.original_name ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/invoice-pool",
|
||||
{
|
||||
|
||||
@@ -136,6 +136,7 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
|
||||
car: "travel_taxi",
|
||||
other: "transport",
|
||||
};
|
||||
const PRINTABLE_WORKFLOW_ACTIONS = new Set(["start", "approve", "reject", "return", "transfer", "withdraw"]);
|
||||
|
||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
@@ -195,6 +196,49 @@ function buildWorkflowProgress(workflowInstance, historyItems = []) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkflowStepNameMap(workflowInstance) {
|
||||
const steps = getWorkflowStepsSnapshot(workflowInstance);
|
||||
return new Map(steps.map((step) => [step.id, step.name ?? step.id]));
|
||||
}
|
||||
|
||||
function resolveApprovalHistoryStepName(historyItem, stepNameMap, fallbackStepId = null) {
|
||||
if (historyItem.workflow_task?.step_name) {
|
||||
return historyItem.workflow_task.step_name;
|
||||
}
|
||||
|
||||
const payload = historyItem.payload ?? {};
|
||||
const stepId =
|
||||
payload.from_step_id ??
|
||||
payload.step_id ??
|
||||
payload.to_step_id ??
|
||||
fallbackStepId ??
|
||||
null;
|
||||
|
||||
if (!stepId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stepNameMap.get(String(stepId)) ?? String(stepId);
|
||||
}
|
||||
|
||||
function buildApprovalHistory(workflowInstance, historyItems = []) {
|
||||
if (!workflowInstance) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stepNameMap = buildWorkflowStepNameMap(workflowInstance);
|
||||
return historyItems
|
||||
.filter((item) => PRINTABLE_WORKFLOW_ACTIONS.has(item.action))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
stepName: resolveApprovalHistoryStepName(item, stepNameMap, workflowInstance.current_step_id),
|
||||
action: item.action,
|
||||
actorName: item.actor?.name ?? "",
|
||||
comment: item.comment ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function ensureApplicantOrAdmin(request, reimbursement) {
|
||||
if (request.employee?.roleCodes?.includes("admin")) {
|
||||
return;
|
||||
@@ -1869,12 +1913,19 @@ async function reimbursementRoutes(fastify) {
|
||||
submitter_id: true,
|
||||
owner_id: true,
|
||||
department_id: true,
|
||||
created_at: true,
|
||||
reimbursement_date: true,
|
||||
total_amount: true,
|
||||
status: true,
|
||||
description: true,
|
||||
project_id: true,
|
||||
linked_travel_request_id: true,
|
||||
department: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
project: { select: { id: true, project_no: true, name: true } },
|
||||
travel_request: {
|
||||
select: {
|
||||
@@ -1917,7 +1968,11 @@ async function reimbursementRoutes(fastify) {
|
||||
status: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
invoice_date: true,
|
||||
seller_name: true,
|
||||
total_amount: true,
|
||||
original_name: true,
|
||||
linked_item_id: true,
|
||||
},
|
||||
},
|
||||
workflow_instances: {
|
||||
@@ -1967,6 +2022,24 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
|
||||
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
|
||||
const latestWorkflowInstance = await fastify.prisma.workflowInstance.findFirst({
|
||||
where: {
|
||||
entity_type: "reimbursement",
|
||||
entity_id: reimbursementId,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
current_step_id: true,
|
||||
created_at: true,
|
||||
context_data: true,
|
||||
workflow_definition: {
|
||||
select: {
|
||||
definition_json: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const workflowHistory = pendingInstance
|
||||
? await fastify.prisma.workflowHistory.findMany({
|
||||
where: { instance_id: pendingInstance.id },
|
||||
@@ -1977,6 +2050,29 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const approvalHistoryRows = latestWorkflowInstance
|
||||
? await fastify.prisma.workflowHistory.findMany({
|
||||
where: { instance_id: latestWorkflowInstance.id },
|
||||
orderBy: { created_at: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
comment: true,
|
||||
payload: true,
|
||||
created_at: true,
|
||||
actor: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
workflow_task: {
|
||||
select: {
|
||||
step_name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
|
||||
where: {
|
||||
entity_type: "reimbursement",
|
||||
@@ -1992,6 +2088,14 @@ async function reimbursementRoutes(fastify) {
|
||||
submitterId: reimbursement.submitter_id,
|
||||
ownerId: reimbursement.owner_id,
|
||||
departmentId: reimbursement.department_id,
|
||||
department: reimbursement.department
|
||||
? {
|
||||
id: reimbursement.department.id,
|
||||
name: reimbursement.department.name,
|
||||
}
|
||||
: null,
|
||||
createdAt: reimbursement.created_at?.toISOString?.() ?? null,
|
||||
submittedAt: latestWorkflowInstance?.created_at?.toISOString?.() ?? null,
|
||||
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
|
||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||
status: reimbursement.status,
|
||||
@@ -2024,7 +2128,11 @@ async function reimbursementRoutes(fastify) {
|
||||
status: item.status,
|
||||
invoiceCode: item.invoice_code,
|
||||
invoiceNumber: item.invoice_number,
|
||||
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
sellerName: item.seller_name ?? null,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
originalName: item.original_name ?? null,
|
||||
linkedItemId: item.linked_item_id ?? null,
|
||||
})),
|
||||
pendingWorkflow: pendingInstance
|
||||
? {
|
||||
@@ -2034,6 +2142,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
: null,
|
||||
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
|
||||
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
|
||||
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户