实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
+31
@@ -34,6 +34,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"openid-client": "^6.8.2",
|
||||
"pdf-lib": "1.17.1",
|
||||
"pg": "^8.18.0",
|
||||
"pg-boss": "^10.3.0",
|
||||
"prisma": "^7.8.0",
|
||||
@@ -3412,6 +3413,24 @@
|
||||
"@opentelemetry/api": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -7562,6 +7581,18 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"openid-client": "^6.8.2",
|
||||
"pdf-lib": "1.17.1",
|
||||
"pg": "^8.18.0",
|
||||
"pg-boss": "^10.3.0",
|
||||
"prisma": "^7.8.0",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "reimbursement_forms"
|
||||
ADD COLUMN "travel_purpose" VARCHAR(200),
|
||||
ADD COLUMN "travel_location" VARCHAR(200),
|
||||
ADD COLUMN "travel_start_date" DATE,
|
||||
ADD COLUMN "travel_end_date" DATE;
|
||||
@@ -1407,6 +1407,10 @@ model ReimbursementForm {
|
||||
paid_by String? @db.Uuid
|
||||
project_id String? @db.Uuid
|
||||
linked_travel_request_id String? @db.Uuid
|
||||
travel_purpose String? @db.VarChar(200)
|
||||
travel_location String? @db.VarChar(200)
|
||||
travel_start_date DateTime? @db.Date
|
||||
travel_end_date DateTime? @db.Date
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
|
||||
+138
-36
@@ -75,6 +75,14 @@ const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
|
||||
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
travelPurpose: z.string().trim().max(200).nullable().optional(),
|
||||
travelLocation: z.string().trim().max(200).nullable().optional(),
|
||||
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
|
||||
}
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
|
||||
@@ -86,7 +94,6 @@ const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
|
||||
.object({ reason: z.string().trim().max(200).optional() })
|
||||
.strict();
|
||||
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
|
||||
const REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA = z.coerce.number().int().min(0).max(39);
|
||||
|
||||
function resolveSignedFileOrigin(config, request) {
|
||||
const configured = String(config.appOrigin ?? "")
|
||||
@@ -123,9 +130,16 @@ const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||
description: z.string().max(500).optional(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
linkedTravelRequestId: z.string().uuid().optional(),
|
||||
travelPurpose: z.string().trim().max(200).optional(),
|
||||
travelLocation: z.string().trim().max(200).optional(),
|
||||
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
|
||||
}
|
||||
if (value.templateId && value.linkedTravelRequestId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -1540,6 +1554,18 @@ async function reimbursementRoutes(fastify) {
|
||||
timeZone: fastify.config.appTimezone,
|
||||
});
|
||||
|
||||
const linkedTravelCities = linkedTravelRequestRecord
|
||||
? [...new Set(linkedTravelRequestRecord.segments.flatMap((segment) => [segment.from_city, segment.to_city]).filter(Boolean))]
|
||||
: [];
|
||||
const travelPurpose = body.travelPurpose?.trim() || linkedTravelRequestRecord?.purpose || null;
|
||||
const travelLocation = body.travelLocation?.trim() || linkedTravelCities.join("、") || null;
|
||||
const travelStartDate = body.travelStartDate
|
||||
? parseDateOnlyString(body.travelStartDate)
|
||||
: linkedTravelRequestRecord?.start_date ?? null;
|
||||
const travelEndDate = body.travelEndDate
|
||||
? parseDateOnlyString(body.travelEndDate)
|
||||
: linkedTravelRequestRecord?.end_date ?? null;
|
||||
|
||||
const created = await tx.reimbursementForm.create({
|
||||
data: {
|
||||
form_number: issued.documentNumber,
|
||||
@@ -1549,6 +1575,10 @@ async function reimbursementRoutes(fastify) {
|
||||
reimbursement_date: parseDateOnlyString(reimbursementDate),
|
||||
description: resolvedDescription,
|
||||
linked_travel_request_id: linkedTravelRequestRecord?.id ?? null,
|
||||
travel_purpose: travelPurpose,
|
||||
travel_location: travelLocation,
|
||||
travel_start_date: travelStartDate,
|
||||
travel_end_date: travelEndDate,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
@@ -1562,6 +1592,10 @@ async function reimbursementRoutes(fastify) {
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
linked_travel_request_id: true,
|
||||
travel_purpose: true,
|
||||
travel_location: true,
|
||||
travel_start_date: true,
|
||||
travel_end_date: true,
|
||||
travel_request: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -1658,6 +1692,12 @@ async function reimbursementRoutes(fastify) {
|
||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
||||
travelSummary: {
|
||||
purpose: reimbursement.travel_purpose ?? null,
|
||||
location: reimbursement.travel_location ?? null,
|
||||
startDate: toDateOnlyString(reimbursement.travel_start_date),
|
||||
endDate: toDateOnlyString(reimbursement.travel_end_date),
|
||||
},
|
||||
items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [],
|
||||
});
|
||||
},
|
||||
@@ -2061,6 +2101,10 @@ async function reimbursementRoutes(fastify) {
|
||||
paid_at: true,
|
||||
project_id: true,
|
||||
linked_travel_request_id: true,
|
||||
travel_purpose: true,
|
||||
travel_location: true,
|
||||
travel_start_date: true,
|
||||
travel_end_date: true,
|
||||
paid_by_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -2261,6 +2305,12 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
});
|
||||
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
|
||||
const travelItemDates = travelItems
|
||||
.map((item) => item.expense_date)
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.getTime() - right.getTime());
|
||||
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: reimbursement.id,
|
||||
@@ -2297,6 +2347,13 @@ async function reimbursementRoutes(fastify) {
|
||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
||||
travelSummary: {
|
||||
isTravel: isTravelReimbursement,
|
||||
purpose: reimbursement.travel_purpose ?? (isTravelReimbursement ? reimbursement.description : null),
|
||||
location: reimbursement.travel_location ?? null,
|
||||
startDate: toDateOnlyString(reimbursement.travel_start_date ?? travelItemDates[0] ?? null),
|
||||
endDate: toDateOnlyString(reimbursement.travel_end_date ?? travelItemDates.at(-1) ?? null),
|
||||
},
|
||||
owner: reimbursement.owner,
|
||||
submitter: reimbursement.submitter,
|
||||
items: reimbursement.items.map((item) => ({
|
||||
@@ -2357,7 +2414,7 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
SELECT id, owner_id, submitter_id, department_id, status, travel_start_date, travel_end_date
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
@@ -2392,6 +2449,20 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
const nextDescription =
|
||||
body.description === undefined ? undefined : body.description?.trim() || null;
|
||||
const nextTravelStartDate = body.travelStartDate === undefined
|
||||
? reimbursement.travel_start_date
|
||||
: body.travelStartDate ? parseDateOnlyString(body.travelStartDate) : null;
|
||||
const nextTravelEndDate = body.travelEndDate === undefined
|
||||
? reimbursement.travel_end_date
|
||||
: body.travelEndDate ? parseDateOnlyString(body.travelEndDate) : null;
|
||||
if (nextTravelStartDate && nextTravelEndDate && nextTravelStartDate > nextTravelEndDate) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { travelEndDate: "invalid_date_range" },
|
||||
});
|
||||
}
|
||||
|
||||
return tx.reimbursementForm.update({
|
||||
where: { id: reimbursementId },
|
||||
@@ -2400,6 +2471,10 @@ async function reimbursementRoutes(fastify) {
|
||||
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
|
||||
: {}),
|
||||
...(body.description !== undefined ? { description: nextDescription } : {}),
|
||||
...(body.travelPurpose !== undefined ? { travel_purpose: body.travelPurpose?.trim() || null } : {}),
|
||||
...(body.travelLocation !== undefined ? { travel_location: body.travelLocation?.trim() || null } : {}),
|
||||
...(body.travelStartDate !== undefined ? { travel_start_date: nextTravelStartDate } : {}),
|
||||
...(body.travelEndDate !== undefined ? { travel_end_date: nextTravelEndDate } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
@@ -2408,6 +2483,10 @@ async function reimbursementRoutes(fastify) {
|
||||
reimbursement_date: true,
|
||||
description: true,
|
||||
total_amount: true,
|
||||
travel_purpose: true,
|
||||
travel_location: true,
|
||||
travel_start_date: true,
|
||||
travel_end_date: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -2418,6 +2497,12 @@ async function reimbursementRoutes(fastify) {
|
||||
reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10),
|
||||
description: updated.description,
|
||||
totalAmount: toMoneyString(updated.total_amount),
|
||||
travelSummary: {
|
||||
purpose: updated.travel_purpose,
|
||||
location: updated.travel_location,
|
||||
startDate: toDateOnlyString(updated.travel_start_date),
|
||||
endDate: toDateOnlyString(updated.travel_end_date),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3003,6 +3088,7 @@ async function reimbursementRoutes(fastify) {
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
@@ -3012,6 +3098,7 @@ async function reimbursementRoutes(fastify) {
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
@@ -3043,6 +3130,7 @@ async function reimbursementRoutes(fastify) {
|
||||
storagePath: invoice.storage_path,
|
||||
mimeType: invoice.mime_type,
|
||||
fileSize: invoice.file_size,
|
||||
fileSha256: invoice.file_sha256,
|
||||
})),
|
||||
...reimbursement.attachments.map((attachment, index) => ({
|
||||
id: attachment.id,
|
||||
@@ -3051,6 +3139,7 @@ async function reimbursementRoutes(fastify) {
|
||||
storagePath: attachment.storage_path,
|
||||
mimeType: attachment.mime_type,
|
||||
fileSize: attachment.file_size,
|
||||
fileSha256: attachment.file_sha256,
|
||||
})),
|
||||
];
|
||||
if (materials.some((material) => !ATTACHMENT_MIME_ALLOWLIST.has(material.mimeType))) {
|
||||
@@ -3079,22 +3168,14 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const origin = resolveSignedFileOrigin(fastify.config, request);
|
||||
const pages = created.bundle.pages.map((page, pageIndex) => {
|
||||
const previewToken = signPayload({
|
||||
purpose: "reimbursement_material_preview",
|
||||
reimbursementId,
|
||||
bundleId: created.bundleId,
|
||||
pageIndex,
|
||||
actorId: request.employee.id,
|
||||
exp: expiresAt,
|
||||
}, fastify.config.jwtSecret);
|
||||
return {
|
||||
url: `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}/${pageIndex}?previewToken=${encodeURIComponent(previewToken)}`,
|
||||
materialType: page.materialType,
|
||||
materialIndex: page.materialIndex,
|
||||
pageNumber: page.pageNumber,
|
||||
};
|
||||
});
|
||||
const previewToken = signPayload({
|
||||
purpose: "reimbursement_material_preview",
|
||||
reimbursementId,
|
||||
bundleId: created.bundleId,
|
||||
actorId: request.employee.id,
|
||||
exp: expiresAt,
|
||||
}, fastify.config.jwtSecret);
|
||||
const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}?previewToken=${encodeURIComponent(previewToken)}`;
|
||||
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
@@ -3106,7 +3187,7 @@ async function reimbursementRoutes(fastify) {
|
||||
extra: {
|
||||
invoiceCount: reimbursement.invoice_pool_items.length,
|
||||
attachmentCount: reimbursement.attachments.length,
|
||||
pageCount: pages.length,
|
||||
pageCount: created.bundle.pageCount,
|
||||
reason,
|
||||
expiresAt,
|
||||
},
|
||||
@@ -3119,30 +3200,31 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
return sendData(reply, request, {
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
pages,
|
||||
signedUrl,
|
||||
originalName: "reimbursement-materials-preview.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: created.fileSize,
|
||||
pageCount: created.bundle.pageCount,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements/:id/materials/preview/:bundleId/:pageIndex",
|
||||
"/reimbursements/:id/materials/preview/:bundleId",
|
||||
{},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedBundleId = UUID_SCHEMA.safeParse(request.params?.bundleId);
|
||||
const parsedPageIndex = REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA.safeParse(request.params?.pageIndex);
|
||||
const previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : "";
|
||||
const verified = previewToken ? verifyToken(previewToken, fastify.config.jwtSecret) : { ok: false };
|
||||
const payload = verified.ok ? verified.payload : null;
|
||||
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
|
||||
if (
|
||||
!parsedBundleId.success ||
|
||||
!parsedPageIndex.success ||
|
||||
!verified.ok ||
|
||||
payload?.purpose !== "reimbursement_material_preview" ||
|
||||
payload?.reimbursementId !== reimbursementId ||
|
||||
payload?.bundleId !== parsedBundleId.data ||
|
||||
payload?.pageIndex !== parsedPageIndex.data ||
|
||||
typeof payload?.exp !== "number" ||
|
||||
payload.exp <= Date.now() ||
|
||||
!parsedActorId.success
|
||||
@@ -3150,10 +3232,7 @@ async function reimbursementRoutes(fastify) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
|
||||
const resolved = materialPreviewStore.getPage({
|
||||
bundleId: parsedBundleId.data,
|
||||
pageIndex: parsedPageIndex.data,
|
||||
});
|
||||
const resolved = materialPreviewStore.getBundle({ bundleId: parsedBundleId.data });
|
||||
if (
|
||||
!resolved ||
|
||||
resolved.bundle.reimbursementId !== reimbursementId ||
|
||||
@@ -3170,19 +3249,17 @@ async function reimbursementRoutes(fastify) {
|
||||
targetType: "reimbursement",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
materialId: resolved.page.materialId,
|
||||
materialType: resolved.page.materialType,
|
||||
materialIndex: resolved.page.materialIndex,
|
||||
pageNumber: resolved.page.pageNumber,
|
||||
pageCount: resolved.bundle.pageCount,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
reply.type("image/png");
|
||||
reply.type("application/pdf");
|
||||
reply.header("cache-control", "private, no-store");
|
||||
reply.header("referrer-policy", "no-referrer");
|
||||
reply.header("content-disposition", "inline");
|
||||
return reply.send(resolved.page.buffer);
|
||||
reply.header("content-disposition", 'inline; filename="reimbursement-materials-preview.pdf"');
|
||||
reply.header("content-length", String(resolved.artifact.buffer.length));
|
||||
return reply.send(resolved.artifact.buffer);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3526,7 +3603,8 @@ async function reimbursementRoutes(fastify) {
|
||||
const submitResult = await fastify.prisma.$transaction(async (tx) => {
|
||||
let budgetCheck = null;
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount
|
||||
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount,
|
||||
linked_travel_request_id, travel_purpose, travel_location, travel_start_date, travel_end_date
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
@@ -3554,6 +3632,30 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
ensureNonNegativeAmount(reimbursement.total_amount);
|
||||
|
||||
const travelItem = await tx.reimbursementItem.findFirst({
|
||||
where: {
|
||||
reimbursement_id: reimbursementId,
|
||||
expense_type: { category: "travel" },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (travelItem || reimbursement.linked_travel_request_id) {
|
||||
const missingTravelFields = [
|
||||
["travelPurpose", reimbursement.travel_purpose],
|
||||
["travelLocation", reimbursement.travel_location],
|
||||
["travelStartDate", reimbursement.travel_start_date],
|
||||
["travelEndDate", reimbursement.travel_end_date],
|
||||
].filter(([, value]) => !value).map(([field]) => field);
|
||||
if (missingTravelFields.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { travelSummary: "incomplete", missingFields: missingTravelFields },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const activeInstance = await tx.workflowInstance.findFirst({
|
||||
where: {
|
||||
entity_type: "reimbursement",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const sharp = require("sharp");
|
||||
|
||||
const FIXED_PDF_DATE = new Date("2000-01-01T00:00:00.000Z");
|
||||
const PDF_CREATOR = "hyb-oa";
|
||||
const PDF_PRODUCER = "hyb-oa reimbursement material merger";
|
||||
|
||||
function materialCollectionHash(materials, mergerVersion) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(String(mergerVersion));
|
||||
for (const material of materials) {
|
||||
hash.update("\0");
|
||||
hash.update([
|
||||
material.materialType,
|
||||
material.id,
|
||||
material.fileSha256 ?? "",
|
||||
material.mimeType,
|
||||
String(material.fileSize),
|
||||
].join("\u001f"));
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function normalizeImageForPdf(source, limits) {
|
||||
const image = sharp(source, { failOn: "error", limitInputPixels: limits.maxImagePixels }).rotate();
|
||||
const metadata = await image.metadata();
|
||||
if (!metadata.width || !metadata.height || metadata.width * metadata.height > limits.maxImagePixels) {
|
||||
throw new Error("material_preview_image_invalid");
|
||||
}
|
||||
const normalized = await image
|
||||
.flatten({ background: "#ffffff" })
|
||||
.jpeg({ quality: 90, chromaSubsampling: "4:4:4", mozjpeg: false })
|
||||
.toBuffer();
|
||||
return { buffer: normalized, width: metadata.width, height: metadata.height };
|
||||
}
|
||||
|
||||
function fitPage(width, height) {
|
||||
const maxSide = 842;
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height));
|
||||
return { width: Math.max(1, width * scale), height: Math.max(1, height * scale) };
|
||||
}
|
||||
|
||||
async function mergeMaterialsToPdf(materials, limits) {
|
||||
const output = await PDFDocument.create();
|
||||
output.setCreationDate(FIXED_PDF_DATE);
|
||||
output.setModificationDate(FIXED_PDF_DATE);
|
||||
output.setCreator(PDF_CREATOR);
|
||||
output.setProducer(PDF_PRODUCER);
|
||||
output.setTitle("Reimbursement Material Preview");
|
||||
output.setSubject("Derived preview; originals retained separately");
|
||||
|
||||
let pageCount = 0;
|
||||
for (const material of materials) {
|
||||
const source = Buffer.from(material.source);
|
||||
if (material.mimeType === "application/pdf") {
|
||||
let input;
|
||||
try {
|
||||
input = await PDFDocument.load(source, { updateMetadata: false });
|
||||
} catch {
|
||||
throw new Error("material_preview_pdf_invalid");
|
||||
}
|
||||
const indices = input.getPageIndices();
|
||||
if (!indices.length || pageCount + indices.length > limits.maxPages) {
|
||||
throw new Error(indices.length ? "material_preview_page_limit" : "material_preview_pdf_empty");
|
||||
}
|
||||
const pages = await output.copyPages(input, indices);
|
||||
pages.forEach((page) => output.addPage(page));
|
||||
pageCount += pages.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pageCount + 1 > limits.maxPages) throw new Error("material_preview_page_limit");
|
||||
let normalized;
|
||||
try {
|
||||
normalized = await normalizeImageForPdf(source, limits);
|
||||
} catch (error) {
|
||||
if (error?.message === "material_preview_image_invalid") throw error;
|
||||
throw new Error("material_preview_image_invalid");
|
||||
}
|
||||
const embedded = await output.embedJpg(normalized.buffer);
|
||||
const size = fitPage(normalized.width, normalized.height);
|
||||
const page = output.addPage([size.width, size.height]);
|
||||
page.drawImage(embedded, { x: 0, y: 0, width: size.width, height: size.height });
|
||||
pageCount += 1;
|
||||
}
|
||||
|
||||
if (!pageCount) throw new Error("material_preview_empty");
|
||||
const bytes = await output.save({
|
||||
useObjectStreams: true,
|
||||
addDefaultPage: false,
|
||||
objectsPerTick: 50,
|
||||
updateFieldAppearances: false,
|
||||
});
|
||||
const buffer = Buffer.from(bytes);
|
||||
if (buffer.length > limits.maxOutputBytes) throw new Error("material_preview_output_size_limit");
|
||||
return { buffer, pageCount };
|
||||
}
|
||||
|
||||
module.exports = { materialCollectionHash, mergeMaterialsToPdf };
|
||||
@@ -1,23 +1,17 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { promisify } = require("node:util");
|
||||
const { execFile } = require("node:child_process");
|
||||
const sharp = require("sharp");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { Worker } = require("node:worker_threads");
|
||||
const { materialCollectionHash } = require("./reimbursementMaterialPdf");
|
||||
|
||||
const MATERIAL_PREVIEW_MERGER_VERSION = "pdf-v1";
|
||||
const MATERIAL_PREVIEW_LIMITS = Object.freeze({
|
||||
maxMaterials: 20,
|
||||
maxSourceBytes: 50 * 1024 * 1024,
|
||||
maxPages: 40,
|
||||
maxPageWidth: 1800,
|
||||
maxPagePixels: 40_000_000,
|
||||
maxOutputBytes: 40 * 1024 * 1024,
|
||||
maxCacheBytes: 80 * 1024 * 1024,
|
||||
rasterDpi: 144,
|
||||
rasterTimeoutMs: 20_000,
|
||||
maxImagePixels: 40_000_000,
|
||||
maxOutputBytes: 50 * 1024 * 1024,
|
||||
maxCacheBytes: 100 * 1024 * 1024,
|
||||
mergeTimeoutMs: 20_000,
|
||||
});
|
||||
|
||||
class MaterialPreviewError extends Error {
|
||||
@@ -28,183 +22,137 @@ class MaterialPreviewError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function pageNumberFromName(fileName) {
|
||||
const matched = fileName.match(/-(\d+)\.png$/i);
|
||||
return matched ? Number(matched[1]) : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
async function normalizePreviewImage(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
|
||||
try {
|
||||
return await sharp(buffer, { failOn: "error", limitInputPixels: limits.maxPagePixels })
|
||||
.rotate()
|
||||
.resize({ width: limits.maxPageWidth, withoutEnlargement: true })
|
||||
.png({ compressionLevel: 8, adaptiveFiltering: true })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
throw new MaterialPreviewError("material_preview_image_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
async function rasterizePdfWithPoppler(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-material-preview-"));
|
||||
const sourcePath = path.join(tempDir, "source.pdf");
|
||||
const outputPrefix = path.join(tempDir, "page");
|
||||
|
||||
try {
|
||||
await fs.writeFile(sourcePath, buffer, { flag: "wx" });
|
||||
try {
|
||||
await execFileAsync(
|
||||
"pdftoppm",
|
||||
[
|
||||
"-png",
|
||||
"-r",
|
||||
String(limits.rasterDpi),
|
||||
"-f",
|
||||
"1",
|
||||
"-l",
|
||||
String(limits.maxPages + 1),
|
||||
"-scale-to-x",
|
||||
String(limits.maxPageWidth),
|
||||
"-scale-to-y",
|
||||
"-1",
|
||||
sourcePath,
|
||||
outputPrefix,
|
||||
],
|
||||
{
|
||||
timeout: limits.rasterTimeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new MaterialPreviewError("material_preview_rasterizer_unavailable");
|
||||
function runMergeWorker(materials, limits) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(path.join(__dirname, "reimbursementMaterialPreviewWorker.js"), {
|
||||
workerData: { materials, limits },
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
worker.terminate().catch(() => {});
|
||||
reject(new MaterialPreviewError("material_preview_merge_timeout"));
|
||||
}, limits.mergeTimeoutMs);
|
||||
timeout.unref?.();
|
||||
worker.once("message", (message) => {
|
||||
clearTimeout(timeout);
|
||||
if (!message?.ok) {
|
||||
reject(new MaterialPreviewError(message?.code || "material_preview_merge_failed"));
|
||||
return;
|
||||
}
|
||||
if (error?.killed || error?.signal) {
|
||||
throw new MaterialPreviewError("material_preview_raster_timeout");
|
||||
resolve({ buffer: Buffer.from(message.bytes), pageCount: message.pageCount });
|
||||
});
|
||||
worker.once("error", () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new MaterialPreviewError("material_preview_merge_failed"));
|
||||
});
|
||||
worker.once("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
clearTimeout(timeout);
|
||||
reject(new MaterialPreviewError("material_preview_merge_failed"));
|
||||
}
|
||||
throw new MaterialPreviewError("material_preview_pdf_invalid");
|
||||
}
|
||||
|
||||
const fileNames = (await fs.readdir(tempDir))
|
||||
.filter((fileName) => /^page-\d+\.png$/i.test(fileName))
|
||||
.sort((left, right) => pageNumberFromName(left) - pageNumberFromName(right));
|
||||
if (!fileNames.length) {
|
||||
throw new MaterialPreviewError("material_preview_pdf_empty");
|
||||
}
|
||||
|
||||
const pages = [];
|
||||
for (const fileName of fileNames) {
|
||||
pages.push(await normalizePreviewImage(await fs.readFile(path.join(tempDir, fileName)), limits));
|
||||
}
|
||||
return pages;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createReimbursementMaterialPreviewStore(options = {}) {
|
||||
const limits = { ...MATERIAL_PREVIEW_LIMITS, ...(options.limits ?? {}) };
|
||||
const rasterizePdf = options.rasterizePdf ?? ((buffer) => rasterizePdfWithPoppler(buffer, limits));
|
||||
const normalizeImage = options.normalizeImage ?? ((buffer) => normalizePreviewImage(buffer, limits));
|
||||
const mergePdf = options.mergePdf ?? ((materials) => runMergeWorker(materials, limits));
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const randomUUID = options.randomUUID ?? (() => crypto.randomUUID());
|
||||
const bundles = new Map();
|
||||
const grants = new Map();
|
||||
const artifacts = new Map();
|
||||
const pendingArtifacts = new Map();
|
||||
let cachedBytes = 0;
|
||||
|
||||
function deleteBundle(bundleId) {
|
||||
const bundle = bundles.get(bundleId);
|
||||
if (!bundle) return;
|
||||
cachedBytes -= bundle.outputBytes;
|
||||
bundles.delete(bundleId);
|
||||
const grant = grants.get(bundleId);
|
||||
if (!grant) return;
|
||||
grants.delete(bundleId);
|
||||
const stillUsed = [...grants.values()].some((candidate) => candidate.cacheKey === grant.cacheKey);
|
||||
if (!stillUsed) {
|
||||
const artifact = artifacts.get(grant.cacheKey);
|
||||
if (artifact) cachedBytes -= artifact.buffer.length;
|
||||
artifacts.delete(grant.cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpired() {
|
||||
const current = now();
|
||||
for (const [bundleId, bundle] of bundles) {
|
||||
if (bundle.expiresAt <= current) deleteBundle(bundleId);
|
||||
for (const [bundleId, grant] of grants) {
|
||||
if (grant.expiresAt <= current) deleteBundle(bundleId);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBundle({ actorId, reimbursementId, materials, expiresAt, readSource }) {
|
||||
pruneExpired();
|
||||
if (!Array.isArray(materials) || materials.length === 0) {
|
||||
throw new MaterialPreviewError("material_preview_empty");
|
||||
}
|
||||
if (materials.length > limits.maxMaterials) {
|
||||
throw new MaterialPreviewError("material_preview_material_limit");
|
||||
}
|
||||
const sourceBytes = materials.reduce((sum, material) => sum + Number(material.fileSize ?? 0), 0);
|
||||
if (!Number.isSafeInteger(sourceBytes) || sourceBytes <= 0 || sourceBytes > limits.maxSourceBytes) {
|
||||
if (!Array.isArray(materials) || !materials.length) throw new MaterialPreviewError("material_preview_empty");
|
||||
if (materials.length > limits.maxMaterials) throw new MaterialPreviewError("material_preview_material_limit");
|
||||
const declaredBytes = materials.reduce((sum, material) => sum + Number(material.fileSize ?? 0), 0);
|
||||
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0 || declaredBytes > limits.maxSourceBytes) {
|
||||
throw new MaterialPreviewError("material_preview_source_size_limit");
|
||||
}
|
||||
|
||||
const pages = [];
|
||||
let outputBytes = 0;
|
||||
let actualSourceBytes = 0;
|
||||
for (const material of materials) {
|
||||
const source = await readSource(material);
|
||||
if (!Buffer.isBuffer(source) || source.length === 0) {
|
||||
throw new MaterialPreviewError("material_preview_source_missing");
|
||||
const collectionHash = materialCollectionHash(materials, MATERIAL_PREVIEW_MERGER_VERSION);
|
||||
const cacheKey = `${reimbursementId}:${collectionHash}:${MATERIAL_PREVIEW_MERGER_VERSION}`;
|
||||
let artifact = artifacts.get(cacheKey);
|
||||
if (!artifact) {
|
||||
let pending = pendingArtifacts.get(cacheKey);
|
||||
if (!pending) pending = (async () => {
|
||||
const loaded = [];
|
||||
let actualBytes = 0;
|
||||
for (const material of materials) {
|
||||
const source = await readSource(material);
|
||||
if (!Buffer.isBuffer(source) || !source.length) throw new MaterialPreviewError("material_preview_source_missing");
|
||||
actualBytes += source.length;
|
||||
if (actualBytes > limits.maxSourceBytes) throw new MaterialPreviewError("material_preview_source_size_limit");
|
||||
loaded.push({ ...material, source });
|
||||
}
|
||||
actualSourceBytes += source.length;
|
||||
if (actualSourceBytes > limits.maxSourceBytes) {
|
||||
throw new MaterialPreviewError("material_preview_source_size_limit");
|
||||
let generated;
|
||||
try {
|
||||
generated = await mergePdf(loaded);
|
||||
} catch (error) {
|
||||
if (error instanceof MaterialPreviewError) throw error;
|
||||
throw new MaterialPreviewError(error?.message || "material_preview_merge_failed");
|
||||
}
|
||||
const renderedPages = material.mimeType === "application/pdf"
|
||||
? await rasterizePdf(source)
|
||||
: [await normalizeImage(source)];
|
||||
for (let index = 0; index < renderedPages.length; index += 1) {
|
||||
if (pages.length >= limits.maxPages) {
|
||||
throw new MaterialPreviewError("material_preview_page_limit");
|
||||
if (!Buffer.isBuffer(generated?.buffer) || !generated.buffer.length) {
|
||||
throw new MaterialPreviewError("material_preview_merge_failed");
|
||||
}
|
||||
if (generated.buffer.length > limits.maxOutputBytes) throw new MaterialPreviewError("material_preview_output_size_limit");
|
||||
if (cachedBytes + generated.buffer.length > limits.maxCacheBytes) {
|
||||
pruneExpired();
|
||||
if (cachedBytes + generated.buffer.length > limits.maxCacheBytes) {
|
||||
throw new MaterialPreviewError("material_preview_cache_limit");
|
||||
}
|
||||
const image = renderedPages[index];
|
||||
outputBytes += image.length;
|
||||
if (outputBytes > limits.maxOutputBytes) {
|
||||
throw new MaterialPreviewError("material_preview_output_size_limit");
|
||||
}
|
||||
pages.push({
|
||||
buffer: image,
|
||||
materialId: material.id,
|
||||
materialType: material.materialType,
|
||||
materialIndex: material.materialIndex,
|
||||
pageNumber: index + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedBytes + outputBytes > limits.maxCacheBytes) {
|
||||
pruneExpired();
|
||||
if (cachedBytes + outputBytes > limits.maxCacheBytes) {
|
||||
throw new MaterialPreviewError("material_preview_cache_limit");
|
||||
}
|
||||
artifacts.set(cacheKey, generated);
|
||||
cachedBytes += generated.buffer.length;
|
||||
return generated;
|
||||
})().finally(() => pendingArtifacts.delete(cacheKey));
|
||||
pendingArtifacts.set(cacheKey, pending);
|
||||
artifact = await pending;
|
||||
}
|
||||
|
||||
const bundleId = randomUUID();
|
||||
const bundle = { actorId, reimbursementId, expiresAt, outputBytes, pages };
|
||||
bundles.set(bundleId, bundle);
|
||||
cachedBytes += outputBytes;
|
||||
const bundle = { actorId, reimbursementId, expiresAt, cacheKey, collectionHash, pageCount: artifact.pageCount };
|
||||
grants.set(bundleId, bundle);
|
||||
const timer = setTimeout(() => deleteBundle(bundleId), Math.max(1, expiresAt - now()));
|
||||
timer.unref?.();
|
||||
return { bundleId, bundle };
|
||||
return { bundleId, bundle, fileSize: artifact.buffer.length };
|
||||
}
|
||||
|
||||
function getPage({ bundleId, pageIndex }) {
|
||||
function getBundle({ bundleId }) {
|
||||
pruneExpired();
|
||||
const bundle = bundles.get(bundleId);
|
||||
if (!bundle || !Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= bundle.pages.length) {
|
||||
return null;
|
||||
}
|
||||
return { bundle, page: bundle.pages[pageIndex] };
|
||||
const bundle = grants.get(bundleId);
|
||||
const artifact = bundle ? artifacts.get(bundle.cacheKey) : null;
|
||||
return bundle && artifact ? { bundle, artifact } : null;
|
||||
}
|
||||
|
||||
return { createBundle, getPage, deleteBundle };
|
||||
return { createBundle, getBundle, deleteBundle };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MATERIAL_PREVIEW_LIMITS,
|
||||
MATERIAL_PREVIEW_MERGER_VERSION,
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
normalizePreviewImage,
|
||||
rasterizePdfWithPoppler,
|
||||
runMergeWorker,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const { parentPort, workerData } = require("node:worker_threads");
|
||||
const { mergeMaterialsToPdf } = require("./reimbursementMaterialPdf");
|
||||
|
||||
mergeMaterialsToPdf(workerData.materials, workerData.limits)
|
||||
.then(({ buffer, pageCount }) => {
|
||||
const bytes = Uint8Array.from(buffer);
|
||||
parentPort.postMessage({ ok: true, bytes, pageCount }, [bytes.buffer]);
|
||||
})
|
||||
.catch((error) => {
|
||||
parentPort.postMessage({ ok: false, code: error?.message || "material_preview_merge_failed" });
|
||||
});
|
||||
@@ -697,9 +697,9 @@ describe("reimbursement attachment routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("issues an ordered image bundle and audits each token-bound page access", async () => {
|
||||
it("issues one token-bound PDF bundle with exact size and audits access", async () => {
|
||||
const bundleId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||
const previewBuffer = Buffer.from("synthetic-png-page");
|
||||
const previewBuffer = Buffer.from("%PDF synthetic merged preview");
|
||||
const materialPreviewStore = {
|
||||
createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
|
||||
bundleId,
|
||||
@@ -707,16 +707,11 @@ describe("reimbursement attachment routes", () => {
|
||||
actorId,
|
||||
reimbursementId,
|
||||
expiresAt,
|
||||
pages: [{
|
||||
buffer: previewBuffer,
|
||||
materialId: materials[0].id,
|
||||
materialType: materials[0].materialType,
|
||||
materialIndex: materials[0].materialIndex,
|
||||
pageNumber: 1,
|
||||
}],
|
||||
pageCount: 1,
|
||||
},
|
||||
fileSize: previewBuffer.length,
|
||||
})),
|
||||
getPage: vi.fn(),
|
||||
getBundle: vi.fn(),
|
||||
};
|
||||
const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
|
||||
|
||||
@@ -738,62 +733,58 @@ describe("reimbursement attachment routes", () => {
|
||||
payload: {},
|
||||
});
|
||||
expect(issued.statusCode).toBe(200);
|
||||
expect(issued.json().data.pages).toEqual([
|
||||
expect.objectContaining({ materialType: "attachment", materialIndex: 1, pageNumber: 1 }),
|
||||
]);
|
||||
const pageUrl = new URL(issued.json().data.pages[0].url);
|
||||
const verified = verifyToken(pageUrl.searchParams.get("previewToken"), JWT_SECRET);
|
||||
expect(issued.json().data).toEqual(expect.objectContaining({
|
||||
mimeType: "application/pdf",
|
||||
fileSize: previewBuffer.length,
|
||||
pageCount: 1,
|
||||
}));
|
||||
const previewUrl = new URL(issued.json().data.signedUrl);
|
||||
const verified = verifyToken(previewUrl.searchParams.get("previewToken"), JWT_SECRET);
|
||||
expect(verified).toEqual(expect.objectContaining({
|
||||
ok: true,
|
||||
payload: expect.objectContaining({
|
||||
purpose: "reimbursement_material_preview",
|
||||
reimbursementId,
|
||||
bundleId,
|
||||
pageIndex: 0,
|
||||
actorId: ACTOR_ID,
|
||||
}),
|
||||
}));
|
||||
|
||||
const issuedToken = pageUrl.searchParams.get("previewToken");
|
||||
const issuedToken = previewUrl.searchParams.get("previewToken");
|
||||
const tamperedToken = `${issuedToken.slice(0, -1)}${issuedToken.endsWith("a") ? "b" : "a"}`;
|
||||
const tamperedResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `${pageUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
|
||||
url: `${previewUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
|
||||
});
|
||||
expect(tamperedResponse.statusCode).toBe(403);
|
||||
expect(materialPreviewStore.getPage).not.toHaveBeenCalled();
|
||||
expect(materialPreviewStore.getBundle).not.toHaveBeenCalled();
|
||||
|
||||
const missingBundleResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `${pageUrl.pathname}${pageUrl.search}`,
|
||||
url: `${previewUrl.pathname}${previewUrl.search}`,
|
||||
});
|
||||
expect(missingBundleResponse.statusCode).toBe(403);
|
||||
|
||||
materialPreviewStore.getPage.mockReturnValue({
|
||||
materialPreviewStore.getBundle.mockReturnValue({
|
||||
bundle: {
|
||||
actorId: ACTOR_ID,
|
||||
reimbursementId,
|
||||
expiresAt: verified.payload.exp,
|
||||
},
|
||||
page: {
|
||||
buffer: previewBuffer,
|
||||
materialId: "bbbbbbbb-bbbb-4bbb-8bbb-000000000001",
|
||||
materialType: "attachment",
|
||||
materialIndex: 1,
|
||||
pageNumber: 1,
|
||||
},
|
||||
artifact: { buffer: previewBuffer },
|
||||
});
|
||||
const pageResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
|
||||
expect(pageResponse.statusCode).toBe(200);
|
||||
expect(pageResponse.rawPayload).toEqual(previewBuffer);
|
||||
expect(pageResponse.headers["content-type"]).toContain("image/png");
|
||||
expect(pageResponse.headers["cache-control"]).toBe("private, no-store");
|
||||
const previewResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
|
||||
expect(previewResponse.statusCode).toBe(200);
|
||||
expect(previewResponse.rawPayload).toEqual(previewBuffer);
|
||||
expect(previewResponse.headers["content-type"]).toContain("application/pdf");
|
||||
expect(Number(previewResponse.headers["content-length"])).toBe(previewBuffer.length);
|
||||
expect(previewResponse.headers["cache-control"]).toBe("private, no-store");
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ action: "REIMBURSEMENT_MATERIAL_PREVIEWED", actor_id: ACTOR_ID }),
|
||||
}));
|
||||
|
||||
prisma.auditLog.create.mockRejectedValueOnce(new Error("synthetic audit outage"));
|
||||
const auditFailureResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
|
||||
const auditFailureResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
|
||||
expect(auditFailureResponse.statusCode).toBe(500);
|
||||
expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
|
||||
} finally {
|
||||
|
||||
@@ -238,6 +238,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
},
|
||||
createdAt: "2026-04-10T09:00:00.000Z",
|
||||
submittedAt: "2026-04-10T10:00:00.000Z",
|
||||
travelSummary: {
|
||||
isTravel: true,
|
||||
purpose: "客户拜访报销",
|
||||
location: null,
|
||||
startDate: "2026-04-09",
|
||||
endDate: "2026-04-09",
|
||||
},
|
||||
invoices: [
|
||||
{
|
||||
id: "invoice-1",
|
||||
|
||||
@@ -99,6 +99,9 @@ async function buildApp(options = {}) {
|
||||
invoicePool: {
|
||||
count: vi.fn(async () => 1),
|
||||
},
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||
},
|
||||
budgetLedgerEntry: {
|
||||
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
|
||||
},
|
||||
@@ -230,6 +233,18 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
resolveRecipientsForAssigneeMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("blocks travel reimbursement submit until the structured summary is complete", async () => {
|
||||
const { app, tx } = await buildApp({ travelItem: true });
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||
travelSummary: "incomplete",
|
||||
missingFields: ["travelPurpose", "travelLocation", "travelStartDate", "travelEndDate"],
|
||||
}));
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns standardChecks from reimbursement precheck", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(
|
||||
buildPrecheck({
|
||||
|
||||
@@ -1,65 +1,100 @@
|
||||
const {
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
} = require("../../../src/services/reimbursementMaterialPreview");
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const sharp = require("sharp");
|
||||
const { mergeMaterialsToPdf } = require("../../../src/services/reimbursementMaterialPdf");
|
||||
const { MaterialPreviewError, createReimbursementMaterialPreviewStore, runMergeWorker } = require("../../../src/services/reimbursementMaterialPreview");
|
||||
|
||||
describe("reimbursement material preview store", () => {
|
||||
it("keeps material and PDF page order in a bounded short-lived bundle", async () => {
|
||||
const limits = { maxPages: 40, maxImagePixels: 40_000_000, maxOutputBytes: 10 * 1024 * 1024 };
|
||||
const ENCRYPTED_PDF = Buffer.from("JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovUHJvZHVjZXIgPGQ1MTZlOTU0YzA+Cj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9QYWdlcwovQ291bnQgMQovS2lkcyBbIDQgMCBSIF0KPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL0NhdGFsb2cKL1BhZ2VzIDIgMCBSCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9UeXBlIC9QYWdlCi9SZXNvdXJjZXMgPDwKPj4KL01lZGlhQm94IFsgMC4wIDAuMCAyMDAgMjAwIF0KL1BhcmVudCAyIDAgUgo+PgplbmRvYmoKNSAwIG9iago8PAovViAyCi9SIDMKL0xlbmd0aCAxMjgKL1AgNDI5NDk2NzI5MgovRmlsdGVyIC9TdGFuZGFyZAovTyA8YTgxN2MwMzIxY2ViOTJjMDQ3MDkyMDc2MTA0NjJkZjViZWIwMzJjYzBhYTQ4YTY3MGY5YWVmNTM3NTk2MzdkNj4KL1UgPDIyOWI4ZWJjYTUzMTFkZWYzZjhiNzM4MzQ1MzExZTdmMjhiZjRlNWU0ZTc1OGE0MTY0MDA0ZTU2ZmZmYTAxMDg+Cj4+CmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTUgMDAwMDAgbiAKMDAwMDAwMDA1OSAwMDAwMCBuIAowMDAwMDAwMTE4IDAwMDAwIG4gCjAwMDAwMDAxNjcgMDAwMDAgbiAKMDAwMDAwMDI2MSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDYKL1Jvb3QgMyAwIFIKL0luZm8gMSAwIFIKL0lEIFsgPDM1Mzk2MzMyMzA2MjYyNjE2NTYzMzgzMjY1MzE2MjM1NjMzNjMzNjM2MTYyNjU2NjM1NjE2NjYxNjU2NjMxMzE+IDwzNTM5NjMzMjMwNjI2MjYxNjU2MzM4MzI2NTMxNjIzNTYzMzYzMzYzNjE2MjY1NjYzNTYxNjY2MTY1NjYzMTMxPiBdCi9FbmNyeXB0IDUgMCBSCj4+CnN0YXJ0eHJlZgo0NzYKJSVFT0YK", "base64");
|
||||
|
||||
async function samplePdf(pageCount = 2) {
|
||||
const pdf = await PDFDocument.create();
|
||||
for (let index = 0; index < pageCount; index += 1) pdf.addPage([300 + index, 200 + index]);
|
||||
return Buffer.from(await pdf.save({ useObjectStreams: true }));
|
||||
}
|
||||
|
||||
describe("reimbursement material PDF preview", () => {
|
||||
it("copies every PDF page, embeds images, and produces deterministic bytes", async () => {
|
||||
const pdf = await samplePdf(2);
|
||||
const png = await sharp({ create: { width: 80, height: 40, channels: 3, background: "white" } }).png().toBuffer();
|
||||
const materials = [
|
||||
{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", source: pdf },
|
||||
{ id: "attachment-1", materialType: "attachment", mimeType: "image/png", source: png },
|
||||
];
|
||||
const first = await mergeMaterialsToPdf(materials, limits);
|
||||
const second = await mergeMaterialsToPdf(materials, limits);
|
||||
expect(first.pageCount).toBe(3);
|
||||
expect(first.buffer.equals(second.buffer)).toBe(true);
|
||||
expect((await PDFDocument.load(first.buffer)).getPageCount()).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps one cached byte sequence per ordered collection and expires its grant", async () => {
|
||||
let currentTime = 1_000;
|
||||
const store = createReimbursementMaterialPreviewStore({
|
||||
now: () => currentTime,
|
||||
randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("pdf-page-1"), Buffer.from("pdf-page-2")]),
|
||||
normalizeImage: vi.fn().mockResolvedValue(Buffer.from("image-page")),
|
||||
});
|
||||
|
||||
const created = await store.createBundle({
|
||||
const merged = Buffer.from("deterministic-pdf");
|
||||
const mergePdf = vi.fn().mockResolvedValue({ buffer: merged, pageCount: 3 });
|
||||
const store = createReimbursementMaterialPreviewStore({ now: () => currentTime, randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", mergePdf });
|
||||
const args = {
|
||||
actorId: "actor-1",
|
||||
reimbursementId: "reimbursement-1",
|
||||
expiresAt: 2_000,
|
||||
materials: [
|
||||
{ id: "invoice-1", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 10 },
|
||||
{ id: "attachment-1", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 10 },
|
||||
],
|
||||
readSource: async (material) => Buffer.from(material.id),
|
||||
});
|
||||
|
||||
expect(created.bundle.pages.map((page) => [page.materialType, page.materialIndex, page.pageNumber])).toEqual([
|
||||
["invoice", 1, 1],
|
||||
["invoice", 1, 2],
|
||||
["attachment", 1, 1],
|
||||
]);
|
||||
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 1 })?.page.buffer.toString()).toBe("pdf-page-2");
|
||||
|
||||
materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 10, fileSha256: "a" }],
|
||||
readSource: async () => Buffer.from("source"),
|
||||
};
|
||||
const created = await store.createBundle(args);
|
||||
expect(created.fileSize).toBe(merged.length);
|
||||
expect(store.getBundle({ bundleId: created.bundleId })?.artifact.buffer).toBe(merged);
|
||||
expect(mergePdf).toHaveBeenCalledTimes(1);
|
||||
currentTime = 2_001;
|
||||
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 0 })).toBeNull();
|
||||
expect(store.getBundle({ bundleId: created.bundleId })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects source and page limits without keeping a partial bundle", async () => {
|
||||
const store = createReimbursementMaterialPreviewStore({
|
||||
limits: { maxMaterials: 1, maxSourceBytes: 10, maxPages: 1 },
|
||||
rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("one"), Buffer.from("two")]),
|
||||
});
|
||||
it("deduplicates concurrent generation for the same ordered collection", async () => {
|
||||
const mergePdf = vi.fn(async () => ({ buffer: Buffer.from("one-pdf"), pageCount: 1 }));
|
||||
let sequence = 0;
|
||||
const store = createReimbursementMaterialPreviewStore({ mergePdf, randomUUID: () => `bundle-${sequence += 1}` });
|
||||
const args = {
|
||||
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 10_000,
|
||||
materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 6, fileSha256: "same" }],
|
||||
readSource: async () => Buffer.from("source"),
|
||||
};
|
||||
await Promise.all([store.createBundle(args), store.createBundle(args)]);
|
||||
expect(mergePdf).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects material/source limits and never keeps a partial bundle", async () => {
|
||||
const store = createReimbursementMaterialPreviewStore({ limits: { maxMaterials: 1, maxSourceBytes: 5 }, mergePdf: vi.fn() });
|
||||
await expect(store.createBundle({
|
||||
actorId: "actor-1",
|
||||
reimbursementId: "reimbursement-1",
|
||||
expiresAt: Date.now() + 1_000,
|
||||
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000,
|
||||
materials: [
|
||||
{ id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
|
||||
{ id: "two", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 6 },
|
||||
{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 },
|
||||
{ id: "two", materialType: "attachment", mimeType: "image/png", fileSize: 3 },
|
||||
],
|
||||
readSource: async () => Buffer.from("source"),
|
||||
})).rejects.toMatchObject({ code: "material_preview_material_limit" });
|
||||
|
||||
await expect(store.createBundle({
|
||||
actorId: "actor-1",
|
||||
reimbursementId: "reimbursement-1",
|
||||
expiresAt: Date.now() + 1_000,
|
||||
materials: [
|
||||
{ id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
|
||||
],
|
||||
readSource: async () => Buffer.from("source"),
|
||||
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000,
|
||||
materials: [{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 }],
|
||||
readSource: async () => Buffer.from("source-too-large"),
|
||||
})).rejects.toBeInstanceOf(MaterialPreviewError);
|
||||
});
|
||||
|
||||
it("fails the whole merge for a damaged PDF", async () => {
|
||||
await expect(mergeMaterialsToPdf([
|
||||
{ id: "bad", materialType: "invoice", mimeType: "application/pdf", source: Buffer.from("not-a-pdf") },
|
||||
], limits)).rejects.toThrow("material_preview_pdf_invalid");
|
||||
});
|
||||
|
||||
it("fails the whole merge for an encrypted PDF without ignoreEncryption", async () => {
|
||||
await expect(mergeMaterialsToPdf([
|
||||
{ id: "encrypted", materialType: "invoice", mimeType: "application/pdf", source: ENCRYPTED_PDF },
|
||||
], limits)).rejects.toThrow("material_preview_pdf_invalid");
|
||||
});
|
||||
|
||||
it("merges a 25-page synthetic document in worker_threads without losing pages", async () => {
|
||||
const pdf = await samplePdf(25);
|
||||
const result = await runMergeWorker([
|
||||
{ id: "multi-page", materialType: "invoice", mimeType: "application/pdf", source: pdf },
|
||||
], { ...limits, mergeTimeoutMs: 10_000 });
|
||||
expect(result.pageCount).toBe(25);
|
||||
expect((await PDFDocument.load(result.buffer)).getPageCount()).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户