feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 539c2cc4dd
当前提交 dc74d9e53f
共修改 23 个文件,包含 4265 行新增和 218 行删除
+183 -105
查看文件
@@ -24,10 +24,19 @@ const MIME_ALLOWLIST = new Set([
]);
const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]);
const bindBodySchema = z.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
});
const bindBodySchema = z.union([
z
.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
})
.strict(),
z
.object({
travelOrderId: z.string().uuid(),
})
.strict(),
]);
const invoiceEditBodySchema = z
.object({
@@ -692,6 +701,7 @@ async function invoicePoolRoutes(fastify) {
id: true,
status: true,
linked_reimbursement_id: true,
travel_order_id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
archived_reason: true,
@@ -718,7 +728,7 @@ async function invoicePoolRoutes(fastify) {
});
}
if (current.linked_reimbursement_id) {
if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -777,7 +787,7 @@ async function invoicePoolRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/unbind",
{
preHandler: fastify.requirePermission("invoice.upload"),
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
@@ -795,7 +805,7 @@ async function invoicePoolRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const invoiceRows = await tx.$queryRaw`
SELECT id, status, linked_reimbursement_id
SELECT id, status, linked_reimbursement_id, travel_order_id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
@@ -810,7 +820,7 @@ async function invoicePoolRoutes(fastify) {
}
const invoice = invoiceRows[0];
if (invoice.status !== "bound" || !invoice.linked_reimbursement_id) {
if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -819,41 +829,51 @@ async function invoicePoolRoutes(fastify) {
});
}
const reimbursementRows = await tx.$queryRaw`
SELECT id, status, owner_id, submitter_id
FROM "reimbursement_forms"
WHERE id = ${invoice.linked_reimbursement_id}::uuid
FOR UPDATE
`;
if (invoice.linked_reimbursement_id) {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status, owner_id, submitter_id
FROM "reimbursement_forms"
WHERE id = ${invoice.linked_reimbursement_id}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_unbind" },
});
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_unbind" },
});
}
} else {
if (!fastify.hasPermission(request.employee, "travel.payment.manage")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
}
responseInvoice = await tx.invoicePool.update({
@@ -862,6 +882,7 @@ async function invoicePoolRoutes(fastify) {
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: null,
updated_by: request.employee.id,
},
select: buildInvoiceDetailSelect(),
@@ -1064,6 +1085,7 @@ async function invoicePoolRoutes(fastify) {
id: true,
status: true,
linked_reimbursement_id: true,
travel_order_id: true,
file_sha256: true,
invoice_code: true,
invoice_number: true,
@@ -1079,7 +1101,7 @@ async function invoicePoolRoutes(fastify) {
});
}
if (current.linked_reimbursement_id) {
if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -1195,7 +1217,7 @@ async function invoicePoolRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/bind",
{
preHandler: fastify.requirePermission("invoice.upload"),
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
@@ -1219,67 +1241,9 @@ async function invoicePoolRoutes(fastify) {
}
const invoiceId = parsedInvoiceId.data;
const { reimbursementId, linkedItemId } = parsedBody.data;
try {
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (![
"draft",
"rejected",
].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
}
if (linkedItemId) {
const linkedItem = await tx.reimbursementItem.findFirst({
where: {
id: linkedItemId,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "item_not_in_reimbursement" },
});
}
}
const invoiceRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
@@ -1299,6 +1263,7 @@ async function invoicePoolRoutes(fastify) {
select: {
status: true,
linked_reimbursement_id: true,
travel_order_id: true,
},
});
@@ -1310,10 +1275,10 @@ async function invoicePoolRoutes(fastify) {
});
}
if (invoice.status === "bound" || invoice.linked_reimbursement_id) {
if (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id) {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
message: "发票已被其他业务单据占用/绑定",
statusCode: 409,
});
}
@@ -1321,18 +1286,129 @@ async function invoicePoolRoutes(fastify) {
if (invoice.status !== "available") {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
message: "发票已被其他业务单据占用/绑定",
statusCode: 409,
details: { status: invoice.status },
});
}
if ("reimbursementId" in parsedBody.data) {
if (!fastify.hasPermission(request.employee, "invoice.upload")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const { reimbursementId, linkedItemId } = parsedBody.data;
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
}
if (linkedItemId) {
const linkedItem = await tx.reimbursementItem.findFirst({
where: {
id: linkedItemId,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "item_not_in_reimbursement" },
});
}
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: linkedItemId ?? null,
travel_order_id: null,
updated_by: request.employee.id,
},
});
return;
}
if (!fastify.hasPermission(request.employee, "travel.payment.manage")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const { travelOrderId } = parsedBody.data;
const travelOrderRows = await tx.$queryRaw`
SELECT id, status
FROM "travel_orders"
WHERE id = ${travelOrderId}::uuid
FOR UPDATE
`;
if (!Array.isArray(travelOrderRows) || travelOrderRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const travelOrder = travelOrderRows[0];
if (!["confirmed", "paid"].includes(travelOrder.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { travelOrder: "invalid_status_for_bind" },
});
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: linkedItemId ?? null,
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: travelOrderId,
updated_by: request.employee.id,
},
});
@@ -1363,6 +1439,7 @@ async function invoicePoolRoutes(fastify) {
status: true,
linked_reimbursement_id: true,
linked_item_id: true,
travel_order_id: true,
total_amount: true,
},
});
@@ -1372,6 +1449,7 @@ async function invoicePoolRoutes(fastify) {
status: invoice.status,
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
linkedTravelOrderId: invoice.travel_order_id,
totalAmount: toMoneyString(invoice.total_amount),
});
},