feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端
这个提交包含在:
+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),
|
||||
});
|
||||
},
|
||||
|
||||
在新工单中引用
屏蔽一个用户