feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力

关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
2026-03-21 18:03:20 +08:00
父节点 9b7cd818f7
当前提交 e39e41287e
共修改 45 个文件,包含 4571 行新增和 328 行删除
+125
查看文件
@@ -0,0 +1,125 @@
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl");
function ensureReadPermission(fastify, request) {
if (
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
!fastify.hasPermission(request.employee, "risk.review.manage")
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
strictDepartment: false,
});
if (!gate.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { featureFlag: "payment_batch_v1_disabled", reason: gate.reason },
});
}
}
function mapBatchSummary(item) {
return {
id: item.id,
batchNo: item.batch_no,
entityType: item.entity_type,
name: item.name,
status: item.status,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
async function apOperationsRoutes(fastify) {
fastify.get("/ap-operations", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureReadPermission(fastify, request);
const [batches, failedItems, openRiskFlags, anomalies] = await Promise.all([
fastify.prisma.paymentBatch.findMany({
where: { status: { in: ["draft", "reviewed", "failed"] } },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 20,
}),
fastify.prisma.paymentBatchItem.findMany({
where: { status: "failed" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
}),
fastify.prisma.entityRiskFlag.findMany({
where: { status: "open" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
}),
fastify.prisma.travelReconciliationItem.findMany({
where: {
resolution_status: "pending",
match_status: { not: "matched" },
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
select: {
id: true,
travel_reconciliation_batch_id: true,
travel_order_id: true,
travel_request_id: true,
match_status: true,
resolution_status: true,
anomaly_code: true,
order_amount: true,
transaction_amount: true,
currency: true,
created_at: true,
},
}),
]);
return sendData(reply, request, {
summary: {
openPaymentBatchCount: batches.length,
failedPaymentBatchItemCount: failedItems.length,
openRiskFlagCount: openRiskFlags.length,
reconciliationAnomalyCount: anomalies.length,
},
openPaymentBatches: batches.map(mapBatchSummary),
failedPaymentBatchItems: failedItems.map((item) => ({
id: item.id,
paymentBatchId: item.payment_batch_id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
})),
openRiskFlags: openRiskFlags.map(buildPersistedRiskFlag),
reconciliationAnomalies: anomalies.map((item) => ({
id: item.id,
travelReconciliationBatchId: item.travel_reconciliation_batch_id,
travelOrderId: item.travel_order_id ?? null,
travelRequestId: item.travel_request_id ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
anomalyCode: item.anomaly_code ?? null,
orderAmount: item.order_amount != null ? toMoneyString(item.order_amount) : null,
transactionAmount: item.transaction_amount != null ? toMoneyString(item.transaction_amount) : null,
currency: item.currency,
createdAt: item.created_at?.toISOString?.() ?? null,
})),
});
});
}
module.exports = apOperationsRoutes;