feat: 统一SearchSelect下拉组件 + 修复营销方案数据 + 平台经济性增强

- 创建SearchSelect搜索下拉组件,选项≤8自动隐藏搜索框
- 全站替换原生select为SearchSelect(13处下拉)
- 修复/revenue/store-ranking优惠率字段名不匹配(discount_rate_pct→avg_discount_rate_pct)
- RevenuePage门店营收明细改用FilterableTable支持筛选排序分页
- 修复/marketing/plans按门店分组改为按marketing_plan分组
- 营销方案分析补充消费额、优惠额列和Top5概览卡片
- PlatformPage门店平台经济性增加门店搜索、经济性优列(平台分色)
- 平台成本率算法说明显示在标题下方
This commit is contained in:
freedakgmail
2026-08-01 23:08:34 +08:00
parent d724a565f5
commit ed7beb7e47
20 changed files with 1035 additions and 286 deletions
+83 -125
View File
@@ -117,20 +117,22 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
const month = parseMonth(req)
const [scorecard, risk, platform, benchmark, action] = await Promise.all([
query(`
SELECT store_code, store_name,
SELECT c002 AS store_code, c003 AS store_name,
count(*) AS bill_count,
count(DISTINCT closed_at::date) AS active_days,
sum(received_total) AS received,
round(sum(received_total) / NULLIF(count(DISTINCT closed_at::date), 0), 2) AS avg_daily_received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(received_total) / NULLIF(sum(guest_count), 0), 2) AS avg_guest_value,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct,
round(count(*) FILTER (WHERE member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
FROM analytics.fact_bill
WHERE store_code = $1
AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')
GROUP BY store_code, store_name
count(DISTINCT c176::date) AS active_days,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)), 2) AS received,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / NULLIF(count(DISTINCT c176::date), 0), 2) AS avg_daily_received,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / count(*), 2) AS avg_bill_value,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c178,'')::numeric,0)), 0), 2) AS avg_guest_value,
round(sum(COALESCE(NULLIF(c068,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c009,'')::numeric,0)), 0) * 100, 2) AS discount_rate_pct,
round(sum(COALESCE(NULLIF(c181,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c114,'')::numeric,0)), 0) * 100, 2) AS theoretical_margin_pct,
round(count(*) FILTER (WHERE NULLIF(c185,'') IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
FROM bill_records
WHERE c002 = $1
AND c176 IS NOT NULL AND c176 != ''
AND c176::timestamp >= $2::date
AND c176::timestamp < ($2::date + interval '1 month')
GROUP BY c002, c003
`, [code, month]),
query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
@@ -183,20 +185,23 @@ router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const storeInfo = await query(`SELECT store_name FROM analytics.mv_store_risk_rating_monthly WHERE store_code = $1 AND month_start = $2::date LIMIT 1`, [code, month])
if (storeInfo.rows.length === 0) { sendSuccess(res, []); return }
const storeName = storeInfo.rows[0].store_name
const result = await query(`
SELECT closed_at::date AS business_date,
SELECT c176::date AS business_date,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
FROM analytics.fact_bill
WHERE store_code = $1
AND closed_at >= $2::date
AND closed_at < ($2::date + interval '1 month')
AND closed_at IS NOT NULL
GROUP BY closed_at::date
round(sum(${RECEIVED_COLS}), 2) AS received,
round(sum(${RECEIVED_COLS}) / count(*), 2) AS avg_bill_value,
round(sum(COALESCE(c068::numeric,0)) / nullif(sum(c009::numeric), 0) * 100, 2) AS discount_rate_pct
FROM bill_records
WHERE c003 = $1
AND c176 IS NOT NULL AND c176 != ''
AND c176::timestamp >= $2::date
AND c176::timestamp < ($2::date + interval '1 month')
GROUP BY c176::date
ORDER BY business_date
`, [code, month])
`, [storeName, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -291,7 +296,7 @@ router.get('/member/comparison', async (req: AuthRequest, res) => {
round(avg(discount_total), 2) AS avg_discount,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.fact_bill
FROM analytics.bill_fact
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END
ORDER BY customer_type
@@ -351,44 +356,32 @@ router.get('/risk/anomaly', async (req: AuthRequest, res) => {
const reason = req.query.reason as string
const cashier = req.query.cashier as string
const conditions: string[] = [
`c175 IS NOT NULL AND c175 != ''`,
`c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')`,
`((c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) OR (c068::numeric > c009::numeric) OR (abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05))`,
]
const conditions: string[] = [`month = to_char($1::date, 'YYYY-MM')`]
const params: any[] = [month]
let paramIdx = 2
if (storeName) {
conditions.push(`c003 = $${paramIdx}`)
conditions.push(`store_name = $${paramIdx}`)
params.push(storeName)
paramIdx++
}
if (reason) {
if (reason === '有消费无实收') conditions.push(`c009::numeric > 0 AND (${RECEIVED_COLS}) = 0`)
else if (reason === '优惠大于消费') conditions.push(`c068::numeric > c009::numeric`)
else if (reason === '消费-优惠与实收不平') conditions.push(`abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 AND NOT (c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AND NOT (c068::numeric > c009::numeric)`)
conditions.push(`anomaly_reason = $${paramIdx}`)
params.push(reason)
paramIdx++
}
if (cashier) {
conditions.push(`c191 ILIKE $${paramIdx}`)
conditions.push(`cashier ILIKE $${paramIdx}`)
params.push(`%${cashier}%`)
paramIdx++
}
const whereClause = conditions.join(' AND ')
const countResult = await query(`SELECT count(*) AS total, round(sum(c009::numeric),2) AS sum_consumption, round(sum(COALESCE(c068::numeric,0)),2) AS sum_discount, round(sum(${RECEIVED_COLS}),2) AS sum_received FROM bill_records WHERE ${whereClause}`, params)
const countResult = await query(`SELECT count(*) AS total, round(sum(consumption),2) AS sum_consumption, round(sum(discount_total),2) AS sum_discount, round(sum(received_total),2) AS sum_received FROM mv_risk_anomaly WHERE ${whereClause}`, params)
const result = await query(`
SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
(${RECEIVED_COLS}) AS received_total,
c191 AS cashier, c176 AS closed_at,
CASE
WHEN c009::numeric > 0 AND (${RECEIVED_COLS}) = 0 THEN '有消费无实收'
WHEN c068::numeric > c009::numeric THEN '优惠大于消费'
WHEN abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 THEN '消费-优惠与实收不平'
END AS anomaly_reason
FROM bill_records
SELECT store_name, bill_no, meal_period, consumption, discount_total, received_total, cashier, closed_at, anomaly_reason
FROM mv_risk_anomaly
WHERE ${whereClause}
ORDER BY c176 DESC
ORDER BY closed_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`, [...params, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize, sum_consumption: countResult.rows[0].sum_consumption, sum_discount: countResult.rows[0].sum_discount, sum_received: countResult.rows[0].sum_received })
@@ -402,26 +395,17 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => {
const month = parseMonth(req)
const storeName = req.query.store as string
let sql = `
SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
0 AS received_total,
c191 AS cashier, c176 AS closed_at,
CASE
WHEN c068::numeric >= c009::numeric AND c009::numeric > 0 THEN '全额优惠'
WHEN c009::numeric = 0 OR c009 IS NULL OR c009 = '' THEN '零消费零实收'
ELSE '有消费无实收'
END AS zero_received_type
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
AND c009::numeric > 0 AND (${RECEIVED_COLS}) = 0
SELECT store_name, bill_no, meal_period, consumption, discount_total,
0 AS received_total, cashier, closed_at, zero_received_type
FROM mv_risk_zero
WHERE month = to_char($1::date, 'YYYY-MM')
`
const params: any[] = [month]
if (storeName) {
sql += ` AND c003 = $2`
sql += ` AND store_name = $2`
params.push(storeName)
}
sql += ` ORDER BY c009::numeric DESC LIMIT 200`
sql += ` ORDER BY consumption DESC LIMIT 200`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -433,18 +417,14 @@ router.get('/risk/cashier', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT c003 AS store_name, c191 AS cashier,
count(*) AS bill_count,
round(sum(${RECEIVED_COLS})::numeric, 2) AS received,
count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AS anomaly_bills,
round(count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric / count(*) * 100, 2) AS anomaly_rate_pct,
round(sum(c009::numeric) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric, 2) AS anomaly_consumption
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
AND c191 IS NOT NULL AND c191 != ''
GROUP BY c003, c191
ORDER BY anomaly_rate_pct DESC NULLS LAST
SELECT store_name, cashier, bill_count,
received,
(anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) AS anomaly_bills,
round((anomaly_no_received + anomaly_discount_over + anomaly_unbalanced)::numeric / bill_count * 100, 2) AS anomaly_rate_pct,
consumption_total
FROM mv_risk_cashier
WHERE month = to_char($1::date, 'YYYY-MM') AND cashier IS NOT NULL AND cashier != ''
ORDER BY (anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -456,17 +436,18 @@ router.get('/marketing/plans', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT store_code, store_name,
SELECT marketing_plan,
count(*) AS bill_count,
sum(received_total) AS received,
sum(discount_total) AS discount_amount,
round(sum(consumption)::numeric, 2) AS consumption,
round(sum(discount_total)::numeric, 2) AS discounts,
round(sum(received_total)::numeric, 2) AS received,
round(avg(received_total), 2) AS avg_bill_value,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(third_party_discount) / NULLIF(sum(received_total), 0) * 100, 2) AS third_party_rate_pct,
count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '') AS plan_bills,
round(count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '')::numeric / count(*) * 100, 2) AS plan_coverage_pct
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY store_code, store_name
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.bill_fact
WHERE marketing_plan IS NOT NULL AND marketing_plan != ''
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY marketing_plan
ORDER BY received DESC
`, [month])
sendSuccess(res, result.rows)
@@ -489,18 +470,10 @@ router.get('/time/weekday', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT EXTRACT(isodow FROM closed_at)::int AS weekday_no,
CASE EXTRACT(isodow FROM closed_at)::int
WHEN 1 THEN '周一' WHEN 2 THEN '周二' WHEN 3 THEN '周三'
WHEN 4 THEN '周四' WHEN 5 THEN '周五' WHEN 6 THEN '周六' ELSE '周日'
END AS weekday,
count(*) AS bill_count,
sum(received_total) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(avg(duration_minutes), 2) AS avg_duration_minutes
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1, 2 ORDER BY 1
SELECT weekday_no, weekday, bill_count, received, avg_bill_value, avg_duration_minutes
FROM mv_time_weekday
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY weekday_no
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -512,15 +485,10 @@ router.get('/time/hourly', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT EXTRACT(hour FROM closed_at)::int AS closing_hour,
count(*) AS bill_count,
sum(received_total) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(avg(duration_minutes), 2) AS avg_duration_minutes
FROM analytics.fact_bill
WHERE closed_at IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1 ORDER BY 1
SELECT closing_hour, bill_count, received, avg_bill_value, avg_duration_minutes
FROM mv_time_hourly
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY closing_hour
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -532,20 +500,10 @@ router.get('/channel', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT closed_at::date AS business_date,
sum(cash_received) AS cash,
sum(alipay_received) AS alipay,
sum(wechat_received) AS wechat,
sum(meituan_received) AS meituan,
sum(unionpay_received) AS unionpay,
sum(douyin_received) AS douyin,
sum(credit_received) AS credit,
sum(jd_delivery_received) AS jd_delivery,
sum(meituan_delivery_received) AS meituan_delivery,
sum(taobao_delivery_received) AS taobao_delivery
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1 ORDER BY 1
SELECT business_date, cash, alipay, wechat, meituan, unionpay, douyin, credit, jd_delivery, meituan_delivery, taobao_delivery
FROM mv_channel_daily
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY business_date
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -567,7 +525,7 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
count(DISTINCT store_code) AS store_count,
min(closed_at)::text AS min_date,
max(closed_at)::text AS max_date
FROM analytics.fact_bill
FROM analytics.bill_fact
`)
const dishStats = await query(`
SELECT
@@ -604,7 +562,7 @@ router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
SELECT meal_period,
count(*) AS network_bills,
(sum(received_total) / NULLIF(count(*), 0)) AS network_avg_bill
FROM analytics.fact_bill
FROM analytics.bill_fact
WHERE meal_period IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY meal_period
@@ -613,7 +571,7 @@ router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
count(*) AS bill_count,
sum(received_total) AS received,
(sum(received_total) / NULLIF(count(*), 0)) AS avg_bill
FROM analytics.fact_bill
FROM analytics.bill_fact
WHERE meal_period IS NOT NULL
AND store_code = $2
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
@@ -712,8 +670,8 @@ router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
const { page, pageSize, offset } = parsePagination(req)
const month = parseMonth(req)
const code = req.params.code
const countResult = await query(`SELECT count(*) AS total FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')`, [code, month])
const result = await query(`SELECT * FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') ORDER BY closed_at DESC LIMIT $3 OFFSET $4`, [code, month, pageSize, offset])
const countResult = await query(`SELECT count(*) AS total FROM mv_risk_anomaly WHERE store_name = (SELECT store_name FROM analytics.dim_store WHERE store_code = $1) AND month = to_char($2::date, 'YYYY-MM')`, [code, month])
const result = await query(`SELECT * FROM mv_risk_anomaly WHERE store_name = (SELECT store_name FROM analytics.dim_store WHERE store_code = $1) AND month = to_char($2::date, 'YYYY-MM') ORDER BY closed_at DESC LIMIT $3 OFFSET $4`, [code, month, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) { sendError(res, err.message) }
})
@@ -734,7 +692,7 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
count(DISTINCT CASE WHEN r.risk_level = '红色' THEN b.store_code END) AS red_count,
count(DISTINCT CASE WHEN r.risk_level = '黄色' THEN b.store_code END) AS yellow_count,
count(DISTINCT CASE WHEN r.risk_level = '绿色' THEN b.store_code END) AS green_count
FROM analytics.fact_bill b
FROM analytics.bill_fact b
JOIN analytics.dim_store d ON d.store_code = b.store_code
LEFT JOIN analytics.mv_store_risk_rating_monthly r ON r.store_code = b.store_code AND r.month_start = $1::date
WHERE d.region IS NOT NULL AND d.region <> ''
@@ -1267,7 +1225,7 @@ router.get('/revenue/store-ranking', async (req: AuthRequest, res) => {
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(discounts)::numeric, 2) AS discounts,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS discount_rate_pct,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS avg_discount_rate_pct,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
round(sum(guests)::numeric, 0) AS guests,
round(avg(theoretical_margin_pct)::numeric, 2) AS avg_theoretical_margin_pct