P25L-1~P25L-5 实现预算控制与AP最小可用运营
合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
@@ -1,7 +1,7 @@
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl");
|
||||
const { BUDGET_RISK_CODES, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||
|
||||
function ensureReadPermission(fastify, request) {
|
||||
if (
|
||||
@@ -30,12 +30,46 @@ async function ensureFeatureEnabled(fastify) {
|
||||
}
|
||||
|
||||
function mapBatchSummary(item) {
|
||||
const totalAmount = toMoneyString((item.items ?? []).reduce((sum, entry) => sum + toMoneyNumber(entry.amount), 0));
|
||||
return {
|
||||
id: item.id,
|
||||
batchNo: item.batch_no,
|
||||
entityType: item.entity_type,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
totalAmount,
|
||||
highRisk: toMoneyNumber(totalAmount) >= 50000,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapFailedPaymentItem(item) {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function mapReconciliationAnomaly(item) {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -45,22 +79,62 @@ async function apOperationsRoutes(fastify) {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
ensureReadPermission(fastify, request);
|
||||
|
||||
const [batches, failedItems, openRiskFlags, anomalies] = await Promise.all([
|
||||
const [
|
||||
batches,
|
||||
pendingPaymentCount,
|
||||
failedItems,
|
||||
failedPaymentItemCount,
|
||||
openRiskFlags,
|
||||
nonBudgetRiskFlagCount,
|
||||
budgetOverspendCount,
|
||||
anomalies,
|
||||
partialMatchCount,
|
||||
] = await Promise.all([
|
||||
fastify.prisma.paymentBatch.findMany({
|
||||
where: { status: { in: ["draft", "reviewed", "failed"] } },
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
amount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
take: 20,
|
||||
}),
|
||||
fastify.prisma.paymentBatchItem.count({
|
||||
where: {
|
||||
payment_batch: {
|
||||
status: { in: ["draft", "reviewed", "failed"] },
|
||||
},
|
||||
status: { in: ["pending", "failed"] },
|
||||
},
|
||||
}),
|
||||
fastify.prisma.paymentBatchItem.findMany({
|
||||
where: { status: "failed" },
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
take: 50,
|
||||
}),
|
||||
fastify.prisma.paymentBatchItem.count({
|
||||
where: { status: "failed" },
|
||||
}),
|
||||
fastify.prisma.entityRiskFlag.findMany({
|
||||
where: { status: "open" },
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
take: 50,
|
||||
}),
|
||||
fastify.prisma.entityRiskFlag.count({
|
||||
where: {
|
||||
status: "open",
|
||||
risk_code: { notIn: BUDGET_RISK_CODES },
|
||||
},
|
||||
}),
|
||||
fastify.prisma.entityRiskFlag.count({
|
||||
where: {
|
||||
status: "open",
|
||||
risk_code: { in: BUDGET_RISK_CODES },
|
||||
},
|
||||
}),
|
||||
fastify.prisma.travelReconciliationItem.findMany({
|
||||
where: {
|
||||
resolution_status: "pending",
|
||||
@@ -82,42 +156,30 @@ async function apOperationsRoutes(fastify) {
|
||||
created_at: true,
|
||||
},
|
||||
}),
|
||||
fastify.prisma.travelReconciliationItem.count({
|
||||
where: {
|
||||
resolution_status: "pending",
|
||||
match_status: { not: "matched" },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const riskFlags = openRiskFlags.map(buildPersistedRiskFlag);
|
||||
const budgetOverspendRisks = riskFlags.filter((item) => BUDGET_RISK_CODES.includes(item.riskCode));
|
||||
const exceptionRiskFlags = riskFlags.filter((item) => !BUDGET_RISK_CODES.includes(item.riskCode));
|
||||
|
||||
return sendData(reply, request, {
|
||||
summary: {
|
||||
openPaymentBatchCount: batches.length,
|
||||
failedPaymentBatchItemCount: failedItems.length,
|
||||
openRiskFlagCount: openRiskFlags.length,
|
||||
reconciliationAnomalyCount: anomalies.length,
|
||||
pendingPaymentCount,
|
||||
partialMatchCount,
|
||||
exceptionCount: failedPaymentItemCount + nonBudgetRiskFlagCount,
|
||||
budgetOverspendCount,
|
||||
},
|
||||
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,
|
||||
})),
|
||||
pendingPaymentBatches: batches.map(mapBatchSummary),
|
||||
failedPaymentBatchItems: failedItems.map(mapFailedPaymentItem),
|
||||
exceptionRiskFlags,
|
||||
budgetOverspendRisks,
|
||||
partialMatchItems: anomalies.map(mapReconciliationAnomaly),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户