feat(server): 完成 P3 后端API与回归(D2~D9)
- workflow: 任务详情/转审候选/列表分页字段(D5) - reimbursements: 列表API + pay凭证字段/幂等增强(D7) - invoice-pool: 列表/详情API + 上传pending_ocr(D9) - ocr/audit: mock provider + 回归脚本 + regression-all开关(D2/D3) - CI: 新增 ci-minimal / ci-regression 工作流(D4)
这个提交包含在:
@@ -15,6 +15,15 @@ const PAY_BODY_SCHEMA = z.object({
|
||||
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
status: z
|
||||
.enum(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"])
|
||||
.optional(),
|
||||
scope: z.enum(["own", "all"]).optional(),
|
||||
});
|
||||
|
||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
if (canViewAll) {
|
||||
@@ -93,6 +102,85 @@ function parsePayBody(body) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseReimbursementListQuery(query) {
|
||||
const parsed = REIMBURSEMENT_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
page: parsed.data.page ?? 1,
|
||||
pageSize: parsed.data.pageSize ?? 20,
|
||||
status: parsed.data.status,
|
||||
scope: parsed.data.scope,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOwnScopeWhere(employeeId) {
|
||||
return {
|
||||
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
|
||||
};
|
||||
}
|
||||
|
||||
function isGlobalViewRole(roleCodes) {
|
||||
return roleCodes.some((code) =>
|
||||
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
|
||||
);
|
||||
}
|
||||
|
||||
function buildViewAllScopeWhere(request) {
|
||||
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ owner_id: request.employee.id },
|
||||
{ submitter_id: request.employee.id },
|
||||
{ owner: { direct_manager_id: request.employee.id } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mapReimbursementSummary(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
formNumber: item.form_number,
|
||||
status: item.status,
|
||||
reimbursementDate: item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
description: item.description,
|
||||
payment: {
|
||||
paymentVoucherNo: item.payment_voucher_no,
|
||||
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannel: item.payment_channel,
|
||||
paidAt: item.paid_at?.toISOString?.() ?? null,
|
||||
paidBy: item.paid_by,
|
||||
},
|
||||
owner: item.owner
|
||||
? {
|
||||
id: item.owner.id,
|
||||
employeeNo: item.owner.employee_no,
|
||||
name: item.owner.name,
|
||||
}
|
||||
: null,
|
||||
submitter: item.submitter
|
||||
? {
|
||||
id: item.submitter.id,
|
||||
employeeNo: item.submitter.employee_no,
|
||||
name: item.submitter.name,
|
||||
}
|
||||
: null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toDateOnlyString(dateValue) {
|
||||
if (!dateValue) {
|
||||
return null;
|
||||
@@ -212,6 +300,82 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseReimbursementListQuery(request.query);
|
||||
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own");
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
|
||||
if (!canViewOwn && !canViewAll) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const scope = query.scope ?? (canViewAll ? "all" : "own");
|
||||
if (scope === "all" && !canViewAll) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const skip = (query.page - 1) * query.pageSize;
|
||||
const where = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(scope === "all"
|
||||
? buildViewAllScopeWhere(request)
|
||||
: buildOwnScopeWhere(request.employee.id)),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.reimbursementForm.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
skip,
|
||||
take: query.pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
form_number: true,
|
||||
status: true,
|
||||
reimbursement_date: true,
|
||||
total_amount: true,
|
||||
description: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
payment_channel: true,
|
||||
paid_at: true,
|
||||
paid_by: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
owner: {
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
},
|
||||
submitter: {
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.reimbursementForm.count({ where }),
|
||||
]);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
scope,
|
||||
items: items.map(mapReimbursementSummary),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements/:id",
|
||||
{
|
||||
|
||||
在新工单中引用
屏蔽一个用户