实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
+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",
|
||||
|
||||
在新工单中引用
屏蔽一个用户