feat: 新增银行授信数据报告页面 - 可配置评分规则+5大分析模块
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user