feat(server): 完成 P2 后端能力(支付幂等、委派回收、OCR异步、审计)
这个提交包含在:
@@ -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",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户