feat(server): 完成 P2 后端能力(支付幂等、委派回收、OCR异步、审计)

这个提交包含在:
2026-02-07 21:54:29 +08:00
父节点 7ea25e9d72
当前提交 c4df9a419a
共修改 23 个文件,包含 2102 行新增和 70 行删除
+82 -1
查看文件
@@ -9,6 +9,11 @@ const {
} = require("../utils/workflow");
const UUID_SCHEMA = z.string().uuid();
const PAY_BODY_SCHEMA = z.object({
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
});
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
@@ -74,6 +79,32 @@ function parseReimbursementId(id) {
return parsed.data;
}
function parsePayBody(body) {
const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function toDateOnlyString(dateValue) {
if (!dateValue) {
return null;
}
if (typeof dateValue === "string") {
return dateValue.slice(0, 10);
}
return new Date(dateValue).toISOString().slice(0, 10);
}
function ensureNonNegativeAmount(totalAmount) {
const asNumber = Number(totalAmount);
if (!Number.isFinite(asNumber)) {
@@ -655,11 +686,12 @@ async function reimbursementRoutes(fastify) {
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const payInput = parsePayBody(request.body);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status
SELECT id, status, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
@@ -683,6 +715,27 @@ async function reimbursementRoutes(fastify) {
});
}
const existingPaymentDate = toDateOnlyString(reimbursement.payment_date);
if (reimbursement.status === "paid") {
const hasVoucherConflict =
payInput.paymentVoucherNo != null &&
payInput.paymentVoucherNo !== (reimbursement.payment_voucher_no ?? null);
const hasPaymentDateConflict =
payInput.paymentDate != null && payInput.paymentDate !== existingPaymentDate;
const hasChannelConflict =
payInput.paymentChannel != null &&
payInput.paymentChannel !== (reimbursement.payment_channel ?? null);
if (hasVoucherConflict || hasPaymentDateConflict || hasChannelConflict) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { payment: "already_paid_conflict" },
});
}
}
const now = new Date();
const archivedResult = await tx.invoicePool.updateMany({
where: {
@@ -702,6 +755,11 @@ async function reimbursementRoutes(fastify) {
where: { id: reimbursementId },
data: {
status: "paid",
payment_voucher_no: payInput.paymentVoucherNo ?? null,
payment_date: payInput.paymentDate ? parseDateOnlyString(payInput.paymentDate) : null,
payment_channel: payInput.paymentChannel ?? null,
paid_at: now,
paid_by: request.employee.id,
updated_by: request.employee.id,
},
});
@@ -713,6 +771,29 @@ async function reimbursementRoutes(fastify) {
reimbursementStatus: "paid",
// archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain.
archivedInvoiceCount: archivedResult.count,
payment: {
paymentVoucherNo:
reimbursement.status === "paid"
? reimbursement.payment_voucher_no ?? null
: payInput.paymentVoucherNo ?? null,
paymentDate:
reimbursement.status === "paid"
? existingPaymentDate
: payInput.paymentDate ?? null,
paymentChannel:
reimbursement.status === "paid"
? reimbursement.payment_channel ?? null
: payInput.paymentChannel ?? null,
paidAt:
reimbursement.status === "paid"
? reimbursement.paid_at?.toISOString?.() ?? null
: now.toISOString(),
paidBy:
reimbursement.status === "paid"
? reimbursement.paid_by ?? null
: request.employee.id,
idempotent: reimbursement.status === "paid",
},
};
});