feat: 新增银行授信数据报告页面 - 可配置评分规则+5大分析模块

This commit is contained in:
freedakgmail
2026-07-30 16:58:32 +08:00
parent 7d1eba43c0
commit 1a4aca28c0
4 changed files with 488 additions and 1 deletions
+79
View File
@@ -660,4 +660,83 @@ router.get('/revenue/daily-summary', async (req: AuthRequest, res) => {
}
})
// 银行授信报告
router.get('/bank/report', async (req: AuthRequest, res) => {
try {
const [overview, daily, waterfall, risk, channel, storeRanking] = await Promise.all([
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = DATE '2026-04-01'`),
query(`SELECT business_date, bill_count, received, avg_bill_value, discount_rate_pct FROM analytics.mv_overview_daily WHERE month = DATE '2026-04-01' ORDER BY business_date`),
query(`
WITH full_scope AS (
SELECT r.received, COALESCE(e.actual_food_cost,0) AS food_cost, COALESCE(e.wage_expense,0) AS wage, COALESCE(e.rent_expense,0) AS rent,
COALESCE(e.utility_expense,0) AS utility, COALESCE(e.dorm_expense,0) AS dorm, COALESCE(e.delivery_commission_expense,0) AS commission,
COALESCE(e.card_fee_expense,0) AS card_fee, COALESCE(e.repair_clean_expense,0) AS repair, COALESCE(e.operating_expense,0) AS operating_expense
FROM analytics.mv_store_risk_rating r
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = DATE '2026-04-01'
WHERE r.received IS NOT NULL
)
SELECT round(sum(received)::numeric,2) AS received, round(sum(food_cost)::numeric,2) AS food_cost, round(sum(wage)::numeric,2) AS wage,
round(sum(rent)::numeric,2) AS rent, round(sum(utility)::numeric,2) AS utility, round(sum(dorm)::numeric,2) AS dorm,
round(sum(commission)::numeric,2) AS commission, round(sum(card_fee+repair)::numeric,2) AS other_expense,
round(sum(operating_expense)::numeric,2) AS total_expense,
round(sum(received)-sum(food_cost)-sum(operating_expense),2) AS net_profit
FROM full_scope
`),
query(`SELECT risk_level, count(*) AS store_count, round(sum(received)::numeric,2) AS total_received, round(avg(theoretical_margin_pct)::numeric,2) AS avg_margin_pct, round(avg(discount_rate_pct)::numeric,2) AS avg_discount_pct FROM analytics.mv_store_risk_rating WHERE received IS NOT NULL GROUP BY risk_level ORDER BY risk_level`),
query(`SELECT round(sum(cash)::numeric,2) AS cash, round(sum(alipay)::numeric,2) AS alipay, round(sum(wechat)::numeric,2) AS wechat, round(sum(meituan)::numeric,2) AS meituan, round(sum(unionpay)::numeric,2) AS unionpay, round(sum(douyin)::numeric,2) AS douyin, round(sum(credit)::numeric,2) AS credit, round(sum(jd_delivery)::numeric,2) AS jd_delivery, round(sum(meituan_delivery)::numeric,2) AS meituan_delivery, round(sum(taobao_delivery)::numeric,2) AS taobao_delivery FROM analytics.v_channel_daily WHERE business_date >= DATE '2026-04-01'`),
query(`SELECT store_code, store_name, round(received::numeric,2) AS received, bill_count, round(avg_bill_value::numeric,2) AS avg_bill_value, round(discount_rate_pct::numeric,2) AS discount_rate_pct, round(theoretical_margin_pct::numeric,2) AS theoretical_margin_pct, risk_level FROM analytics.mv_store_risk_rating WHERE received IS NOT NULL ORDER BY received DESC`),
])
const ov = overview.rows[0] as any
const dailyRows = daily.rows.map((r: any) => ({
...r,
received: parseFloat(r.received),
bill_count: parseInt(r.bill_count),
avg_bill_value: parseFloat(r.avg_bill_value),
discount_rate_pct: parseFloat(r.discount_rate_pct),
}))
const wf = waterfall.rows[0] as any
const riskRows = risk.rows.map((r: any) => ({
...r,
store_count: parseInt(r.store_count),
total_received: parseFloat(r.total_received),
}))
const ch = channel.rows[0] as any
const channelTotals: Record<string, number> = {}
if (ch) {
for (const [k, v] of Object.entries(ch)) {
const val = parseFloat(v as string)
if (val > 0) channelTotals[k] = val
}
}
const storeRows = storeRanking.rows.map((r: any) => ({
...r,
received: parseFloat(r.received),
bill_count: parseInt(r.bill_count),
avg_bill_value: parseFloat(r.avg_bill_value),
discount_rate_pct: parseFloat(r.discount_rate_pct),
theoretical_margin_pct: parseFloat(r.theoretical_margin_pct),
}))
// 计算日度营收波动率
const receivedArr = dailyRows.map((d: any) => d.received)
const meanRev = receivedArr.reduce((a: number, b: number) => a + b, 0) / (receivedArr.length || 1)
const variance = receivedArr.reduce((s: number, v: number) => s + Math.pow(v - meanRev, 2), 0) / (receivedArr.length || 1)
const stdDev = Math.sqrt(variance)
const cv = meanRev > 0 ? stdDev / meanRev : 0
sendSuccess(res, {
overview: { ...ov, received: parseFloat(ov?.received), bill_count: parseInt(ov?.bill_count), avg_bill_value: parseFloat(ov?.avg_bill_value), discount_rate_pct: parseFloat(ov?.discount_rate_pct), theoretical_margin_pct: parseFloat(ov?.theoretical_margin_pct), member_bills: parseInt(ov?.member_bills), member_share_pct: parseFloat(ov?.member_share_pct) },
daily: dailyRows,
waterfall: { ...wf, received: parseFloat(wf?.received), food_cost: parseFloat(wf?.food_cost), wage: parseFloat(wf?.wage), rent: parseFloat(wf?.rent), utility: parseFloat(wf?.utility), dorm: parseFloat(wf?.dorm), commission: parseFloat(wf?.commission), other_expense: parseFloat(wf?.other_expense), total_expense: parseFloat(wf?.total_expense), net_profit: parseFloat(wf?.net_profit) },
risk: riskRows,
channel: channelTotals,
stores: storeRows,
stability: { mean_daily_revenue: meanRev, std_dev: stdDev, cv: cv, days: receivedArr.length },
})
} catch (err: any) {
sendError(res, err.message)
}
})
export default router