V3.0: 门店分级与风险等级页面、数据填充脚本、后端API

- 新增V3.0数据库表结构(12-15)和数据填充脚本(16)
- 新增后端路由: store-grade, enterprise, product, intelligence, alert, scheduler, target
- 新增前端页面: StoreGradePage, EnterprisePage, ProductLifecyclePage, IntelligencePage, AlertManagementPage, DataImportPage, SchedulerManagementPage, TargetManagementPage
- 门店分级: 自动分级算法(达成率40%+毛利率25%+会员15%+风险20%)
- 风险等级: 基于mv_store_risk_rating模型展示风险分布和原因
- 弹窗: 雷达图+详细指标+分级原因+风险原因(primary_issue)
- FilterableTable: filterOptions支持{value,label}对象格式
This commit is contained in:
freedakgmail
2026-08-05 20:09:39 +08:00
parent 814abf4a68
commit d04e837d9c
30 changed files with 5052 additions and 144 deletions
+336
View File
@@ -0,0 +1,336 @@
import { Router } from 'express'
import { query, withTransaction } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// 年度战略目标 (T-101)
// ============================================================
router.get('/annual', async (req: AuthRequest, res) => {
try {
const year = parseInt((req.query.year as string) || new Date().getFullYear().toString())
const result = await query(
`SELECT * FROM analytics.v3_annual_target WHERE year = $1 ORDER BY metric`, [year]
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/annual', async (req: AuthRequest, res) => {
try {
const { year, metric, target_value, description } = req.body
const result = await query(`
INSERT INTO analytics.v3_annual_target (year, metric, target_value, description, created_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (year, metric) DO UPDATE SET
target_value = EXCLUDED.target_value,
description = EXCLUDED.description,
updated_at = NOW()
RETURNING *
`, [year, metric, target_value, description, req.user?.name || 'system'])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
router.delete('/annual/:id', async (req: AuthRequest, res) => {
try {
await query(`DELETE FROM analytics.v3_annual_target WHERE id = $1`, [req.params.id])
sendSuccess(res, { deleted: true })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 区域年度目标 (T-102)
// ============================================================
router.get('/regional', async (req: AuthRequest, res) => {
try {
const year = parseInt((req.query.year as string) || new Date().getFullYear().toString())
const result = await query(
`SELECT * FROM analytics.v3_regional_target WHERE year = $1 ORDER BY region`, [year]
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/regional', async (req: AuthRequest, res) => {
try {
const { year, region, revenue_target, profit_target, store_count_target, member_count_target, weight_pct } = req.body
const result = await query(`
INSERT INTO analytics.v3_regional_target (year, region, revenue_target, profit_target, store_count_target, member_count_target, weight_pct)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (year, region) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
profit_target = EXCLUDED.profit_target,
store_count_target = EXCLUDED.store_count_target,
member_count_target = EXCLUDED.member_count_target,
weight_pct = EXCLUDED.weight_pct,
updated_at = NOW()
RETURNING *
`, [year, region, revenue_target, profit_target, store_count_target, member_count_target, weight_pct || 100])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 门店月度目标 (T-103)
// ============================================================
router.get('/store-monthly', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + '-01'
const storeCode = req.query.store_code as string
const conditions = [`month = $1`]
const params: any[] = [month]
if (storeCode) {
conditions.push(`store_code = $2`)
params.push(storeCode)
}
const result = await query(
`SELECT * FROM analytics.v3_store_monthly_target WHERE ${conditions.join(' AND ')} ORDER BY store_code`,
params
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/store-monthly', async (req: AuthRequest, res) => {
try {
const { month, store_code, store_name, region, grade, revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target, profit_target } = req.body
const year = new Date(month).getFullYear()
const result = await query(`
INSERT INTO analytics.v3_store_monthly_target (year, month, store_code, store_name, region, grade, revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target, profit_target)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (month, store_code) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
bill_count_target = EXCLUDED.bill_count_target,
avg_bill_value_target = EXCLUDED.avg_bill_value_target,
member_penetration_target = EXCLUDED.member_penetration_target,
cost_rate_target = EXCLUDED.cost_rate_target,
profit_target = EXCLUDED.profit_target,
grade = EXCLUDED.grade,
updated_at = NOW()
RETURNING *
`, [year, month, store_code, store_name, region, grade || 'B', revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target, profit_target])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 日级目标 (T-104)
// ============================================================
router.get('/daily', async (req: AuthRequest, res) => {
try {
const targetDate = (req.query.date as string) || new Date().toISOString().slice(0, 10)
const storeCode = req.query.store_code as string
const conditions = [`target_date = $1`]
const params: any[] = [targetDate]
if (storeCode) {
conditions.push(`store_code = $2`)
params.push(storeCode)
}
const result = await query(
`SELECT * FROM analytics.v3_daily_target WHERE ${conditions.join(' AND ')} ORDER BY store_code`,
params
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 个人任务 (T-105)
// ============================================================
router.get('/personal-tasks', async (req: AuthRequest, res) => {
try {
const targetDate = (req.query.date as string) || new Date().toISOString().slice(0, 10)
const storeCode = req.query.store_code as string
const conditions = [`target_date = $1`]
const params: any[] = [targetDate]
if (storeCode) {
conditions.push(`store_code = $2`)
params.push(storeCode)
}
const result = await query(
`SELECT * FROM analytics.v3_personal_task WHERE ${conditions.join(' AND ')} ORDER BY employee_name`,
params
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 目标自动拆解算法 (T-106, T-107)
// ============================================================
// T-106: 年度→区域→门店 拆解
router.post('/decompose/annual-to-store', async (req: AuthRequest, res) => {
try {
const { year, month } = req.body
const targetMonth = month || `${year}-${String(new Date().getMonth() + 1).padStart(2, '0')}-01`
const annualResult = await query(
`SELECT metric, target_value FROM analytics.v3_annual_target WHERE year = $1`, [year]
)
const revenueTarget = annualResult.rows.find((r: any) => r.metric === 'revenue')
if (!revenueTarget) {
return sendError(res, `未找到 ${year} 年营收目标,请先设定年度目标`)
}
const regionalResult = await query(
`SELECT region, weight_pct FROM analytics.v3_regional_target WHERE year = $1 ORDER BY region`, [year]
)
await withTransaction(async (client) => {
for (const region of regionalResult.rows) {
const storesResult = await client.query(`
SELECT store_code, store_name, region FROM analytics.dim_store
WHERE region = $1 AND close_date IS NULL
ORDER BY store_code
`, [region.region])
const storeCount = storesResult.rows.length
if (storeCount === 0) continue
const regionalRevenue = Number(revenueTarget.target_value) * Number(region.weight_pct) / 100
const gradesResult = await client.query(`
SELECT store_code, grade FROM analytics.v3_store_grade
WHERE grade_month = DATE_TRUNC('month', $1::date)::date
`, [targetMonth])
const gradeMap = new Map(gradesResult.rows.map((r: any) => [r.store_code, r.grade]))
const weights = storesResult.rows.map((s: any) => {
const grade = gradeMap.get(s.store_code) || 'B'
switch (grade) {
case 'A': return 1.1
case 'B': return 1.0
case 'C': return 0.9
case 'D': return 0.7
default: return 1.0
}
})
const totalWeight = weights.reduce((a: number, b: number) => a + b, 0)
for (let i = 0; i < storesResult.rows.length; i++) {
const store = storesResult.rows[i]
const storeRevenue = (regionalRevenue / 12) * (weights[i] / totalWeight)
await client.query(`
INSERT INTO analytics.v3_store_monthly_target (year, month, store_code, store_name, region, grade, revenue_target)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (month, store_code) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
grade = EXCLUDED.grade,
updated_at = NOW()
`, [year, targetMonth, store.store_code, store.store_name, store.region, gradeMap.get(store.store_code) || 'B', storeRevenue.toFixed(2)])
}
}
})
const countResult = await query(
`SELECT count(*) AS total FROM analytics.v3_store_monthly_target WHERE month = $1`, [targetMonth]
)
sendSuccess(res, { month: targetMonth, stores: parseInt(countResult.rows[0].total) })
} catch (err: any) {
sendError(res, err.message)
}
})
// T-107: 月度→日 拆解
router.post('/decompose/monthly-to-daily', async (req: AuthRequest, res) => {
try {
const { month } = req.body
const targetMonth = month || new Date().toISOString().slice(0, 8) + '01'
const monthlyResult = await query(
`SELECT store_code, store_name, revenue_target, bill_count_target FROM analytics.v3_store_monthly_target WHERE month = $1`,
[targetMonth]
)
const year = parseInt(targetMonth.slice(0, 4))
const monthNum = parseInt(targetMonth.slice(5, 7))
const daysInMonth = new Date(year, monthNum, 0).getDate()
await withTransaction(async (client) => {
for (const target of monthlyResult.rows) {
const dailyRevenue = Number(target.revenue_target) / daysInMonth
const dailyBills = Number(target.bill_count_target || 0) / daysInMonth
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${targetMonth.slice(0, 8)}${String(day).padStart(2, '0')}`
const weight = (100 / daysInMonth).toFixed(2)
await client.query(`
INSERT INTO analytics.v3_daily_target (target_date, store_code, store_name, revenue_target, bill_count_target, weight_pct)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (target_date, store_code) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
bill_count_target = EXCLUDED.bill_count_target,
weight_pct = EXCLUDED.weight_pct
`, [dateStr, target.store_code, target.store_name, dailyRevenue.toFixed(2), Math.round(dailyBills), weight])
}
}
})
sendSuccess(res, { month: targetMonth, days: daysInMonth, stores: monthlyResult.rows.length })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 达成率计算
// ============================================================
router.get('/achievement', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + '-01'
const result = await query(`
SELECT
t.store_code, t.store_name, t.region, t.grade,
t.revenue_target,
COALESCE(s.received, 0) AS actual_revenue,
CASE WHEN t.revenue_target > 0
THEN ROUND(COALESCE(s.received, 0) / t.revenue_target * 100, 2)
ELSE 0 END AS achievement_rate,
t.bill_count_target,
COALESCE(s.bill_count, 0) AS actual_bill_count,
CASE WHEN t.bill_count_target > 0
THEN ROUND(COALESCE(s.bill_count, 0) / t.bill_count_target * 100, 2)
ELSE 0 END AS bill_achievement_rate
FROM analytics.v3_store_monthly_target t
LEFT JOIN analytics.mv_store_risk_rating_monthly s
ON s.store_code = t.store_code AND s.month_start = t.month
WHERE t.month = $1
ORDER BY achievement_rate DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router