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:
@@ -0,0 +1,320 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ============================================================
|
||||
// 产品生命周期 (T-170, T-173)
|
||||
// ============================================================
|
||||
|
||||
router.get('/products', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const stage = req.query.stage as string
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
if (stage) { conditions.push(`lifecycle_stage = $${params.length + 1}`); params.push(stage) }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const result = await query(`SELECT * FROM analytics.v3_product_lifecycle ${where} ORDER BY launch_date DESC`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/products', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { sku_code, sku_name, launch_date, lifecycle_stage } = req.body
|
||||
const result = await query(`
|
||||
INSERT INTO analytics.v3_product_lifecycle (sku_code, sku_name, launch_date, lifecycle_stage)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (sku_code) DO UPDATE SET sku_name = EXCLUDED.sku_name, lifecycle_stage = EXCLUDED.lifecycle_stage, updated_at = NOW()
|
||||
RETURNING *
|
||||
`, [sku_code, sku_name, launch_date, lifecycle_stage || '爬坡'])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-173: 产品生命周期自动追踪
|
||||
router.post('/products/:id/track', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_product_lifecycle WHERE id = $1`, [req.params.id])
|
||||
if (result.rows.length === 0) return sendError(res, 'Product not found', 404)
|
||||
const product = result.rows[0]
|
||||
const launchDate = new Date(product.launch_date)
|
||||
const now = new Date()
|
||||
const daysSinceLaunch = Math.floor((now.getTime() - launchDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
let stage = product.lifecycle_stage
|
||||
let survivalStatus = product.survival_status
|
||||
let firstWeekReportDate = product.first_week_report_date
|
||||
let day90ReportDate = product.day90_report_date
|
||||
|
||||
if (daysSinceLaunch <= 7) stage = '爬坡'
|
||||
else if (daysSinceLaunch <= 90) stage = '成熟'
|
||||
else stage = '衰退'
|
||||
|
||||
if (daysSinceLaunch >= 7 && !firstWeekReportDate) firstWeekReportDate = new Date().toISOString().slice(0, 10)
|
||||
if (daysSinceLaunch >= 90 && !day90ReportDate) {
|
||||
day90ReportDate = new Date().toISOString().slice(0, 10)
|
||||
survivalStatus = daysSinceLaunch > 180 ? '淘汰' : '存活'
|
||||
}
|
||||
|
||||
await query(`UPDATE analytics.v3_product_lifecycle SET lifecycle_stage = $1, survival_status = $2, first_week_report_date = $3, day90_report_date = $4, updated_at = NOW() WHERE id = $5`,
|
||||
[stage, survivalStatus, firstWeekReportDate, day90ReportDate, req.params.id])
|
||||
sendSuccess(res, { tracked: true, daysSinceLaunch, stage, survivalStatus })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 顾客评价 (T-171, T-172)
|
||||
// ============================================================
|
||||
|
||||
router.get('/reviews', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const storeCode = req.query.store_code as string
|
||||
const category = req.query.category as string
|
||||
const sentiment = req.query.sentiment as string
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
if (storeCode) { conditions.push(`store_code = $${params.length + 1}`); params.push(storeCode) }
|
||||
if (category) { conditions.push(`nlp_category = $${params.length + 1}`); params.push(category) }
|
||||
if (sentiment) { conditions.push(`nlp_sentiment = $${params.length + 1}`); params.push(sentiment) }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const countResult = await query(`SELECT count(*) AS total FROM analytics.v3_customer_review ${where}`, params)
|
||||
const result = await query(`SELECT * FROM analytics.v3_customer_review ${where} ORDER BY review_date DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, pageSize, offset])
|
||||
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/reviews', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { review_source, store_code, store_name, sku_code, sku_name, rating, content, review_date } = req.body
|
||||
const nlpCategory = analyzeReviewCategory(content)
|
||||
const nlpSentiment = analyzeSentiment(content, rating)
|
||||
const result = await query(`
|
||||
INSERT INTO analytics.v3_customer_review (review_source, store_code, store_name, sku_code, sku_name, rating, content, nlp_category, nlp_sentiment, review_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *
|
||||
`, [review_source, store_code, store_name, sku_code, sku_name, rating, content, nlpCategory, nlpSentiment, review_date])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-172: 简单NLP归因(基于关键词匹配)
|
||||
function analyzeReviewCategory(content: string): string {
|
||||
if (!content) return '其他'
|
||||
const lower = content.toLowerCase()
|
||||
if (/口味|味道|好吃|难吃|咸|淡|甜|辣|鲜/.test(lower)) return '口味'
|
||||
if (/服务|态度|慢|快|热情|冷漠/.test(lower)) return '服务'
|
||||
if (/环境|卫生|干净|脏|吵|安静/.test(lower)) return '环境'
|
||||
if (/异物|虫|头发|沙子|变质/.test(lower)) return '异物'
|
||||
if (/分量|少|多|够|不够/.test(lower)) return '分量'
|
||||
return '其他'
|
||||
}
|
||||
|
||||
function analyzeSentiment(content: string, rating: number): string {
|
||||
if (rating >= 4) return '正面'
|
||||
if (rating <= 2) return '负面'
|
||||
if (content && /好|赞|满意|喜欢/.test(content)) return '正面'
|
||||
if (content && /差|烂|失望|难吃/.test(content)) return '负面'
|
||||
return '中性'
|
||||
}
|
||||
|
||||
// 评价统计概览
|
||||
router.get('/reviews/overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const storeCode = req.query.store_code as string
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
if (storeCode) { conditions.push(`store_code = $${params.length + 1}`); params.push(storeCode) }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const result = await query(`
|
||||
SELECT
|
||||
count(*) AS total_reviews,
|
||||
avg(rating) AS avg_rating,
|
||||
count(*) FILTER (WHERE nlp_sentiment = '正面') AS positive,
|
||||
count(*) FILTER (WHERE nlp_sentiment = '负面') AS negative,
|
||||
count(*) FILTER (WHERE nlp_sentiment = '中性') AS neutral,
|
||||
count(*) FILTER (WHERE nlp_category = '口味') AS taste_count,
|
||||
count(*) FILTER (WHERE nlp_category = '服务') AS service_count,
|
||||
count(*) FILTER (WHERE nlp_category = '环境') AS env_count,
|
||||
count(*) FILTER (WHERE nlp_category = '异物') AS foreign_matter_count,
|
||||
count(*) FILTER (WHERE nlp_category = '分量') AS portion_count
|
||||
FROM analytics.v3_customer_review ${where}
|
||||
`, params)
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 数据导入管道 (T-150~156)
|
||||
// ============================================================
|
||||
|
||||
router.get('/import-logs', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const importType = req.query.type as string
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
if (importType) { conditions.push(`import_type = $${params.length + 1}`); params.push(importType) }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const result = await query(`SELECT * FROM analytics.v3_data_import_log ${where} ORDER BY created_at DESC LIMIT 100`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/import/trigger', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { import_type } = req.body
|
||||
const result = await query(`
|
||||
INSERT INTO analytics.v3_data_import_log (import_type, status, started_at)
|
||||
VALUES ($1, 'running', NOW()) RETURNING *
|
||||
`, [import_type])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-153: 数据质量规则
|
||||
router.get('/quality-rules', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_data_quality_rule ORDER BY id`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-154: 库存快照
|
||||
router.get('/inventory-snapshot', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const date = (req.query.date as string) || new Date().toISOString().slice(0, 10)
|
||||
const storeCode = req.query.store_code as string
|
||||
const conditions = [`snapshot_date = $1`]
|
||||
const params: any[] = [date]
|
||||
if (storeCode) { conditions.push(`store_code = $2`); params.push(storeCode) }
|
||||
const result = await query(`SELECT * FROM analytics.v3_inventory_snapshot WHERE ${conditions.join(' AND ')} ORDER BY store_code, sku_code`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 月度报告 (T-195)
|
||||
// ============================================================
|
||||
|
||||
router.get('/reports', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const reportType = req.query.type as string
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
if (reportType) { conditions.push(`report_type = $${params.length + 1}`); params.push(reportType) }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const result = await query(`SELECT * FROM analytics.v3_monthly_report ${where} ORDER BY report_month DESC LIMIT 50`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/reports/generate', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { report_month, report_type, store_code } = req.body
|
||||
const monthStart = report_month || new Date().toISOString().slice(0, 8) + '01'
|
||||
|
||||
const achievementResult = await query(`
|
||||
SELECT store_code, store_name,
|
||||
revenue_target, COALESCE(s.received, 0) AS actual_revenue,
|
||||
CASE WHEN revenue_target > 0 THEN ROUND(COALESCE(s.received, 0) / revenue_target * 100, 2) ELSE 0 END AS 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 ${store_code ? 'AND t.store_code = $2' : ''}
|
||||
ORDER BY achievement_rate DESC
|
||||
`, store_code ? [monthStart, store_code] : [monthStart])
|
||||
|
||||
const avgRate = achievementResult.rows.length > 0
|
||||
? (achievementResult.rows.reduce((s: number, r: any) => s + Number(r.achievement_rate || 0), 0) / achievementResult.rows.length).toFixed(1)
|
||||
: '0.0'
|
||||
const summary = `月度报告: ${achievementResult.rows.length}家门店, 平均达成率${avgRate}%`
|
||||
const content = JSON.stringify({ stores: achievementResult.rows })
|
||||
|
||||
const result = await query(`
|
||||
INSERT INTO analytics.v3_monthly_report (report_month, report_type, store_code, content, summary)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (report_month, report_type, store_code) DO UPDATE SET content = EXCLUDED.content, summary = EXCLUDED.summary
|
||||
RETURNING *
|
||||
`, [monthStart, report_type || 'monthly', store_code || null, content, summary])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 目标偏差分析 (T-112)
|
||||
// ============================================================
|
||||
|
||||
router.get('/variance', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
|
||||
const result = await query(`SELECT * FROM analytics.v3_target_variance WHERE report_month = $1 ORDER BY achievement_pct ASC`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/variance/generate', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = (req.body.month as string) || new Date().toISOString().slice(0, 8) + '01'
|
||||
const achievementResult = await query(`
|
||||
SELECT t.store_code, t.store_name, 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_pct
|
||||
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
|
||||
`, [month])
|
||||
|
||||
for (const row of achievementResult.rows) {
|
||||
const variance = Number(row.actual_revenue) - Number(row.revenue_target)
|
||||
const reasons = []
|
||||
if (Number(row.achievement_pct) < 90) reasons.push('营收未达标')
|
||||
if (Number(row.achievement_pct) < 70) reasons.push('客流可能不足')
|
||||
if (Number(row.achievement_pct) < 50) reasons.push('需根因分析')
|
||||
|
||||
await query(`
|
||||
INSERT INTO analytics.v3_target_variance (report_month, store_code, store_name, revenue_target, revenue_actual, achievement_pct, variance_amount, variance_reasons)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (report_month, store_code) DO UPDATE SET revenue_actual = EXCLUDED.revenue_actual, achievement_pct = EXCLUDED.achievement_pct, variance_amount = EXCLUDED.variance_amount, variance_reasons = EXCLUDED.variance_reasons
|
||||
`, [month, row.store_code, row.store_name, row.revenue_target, row.actual_revenue, row.achievement_pct, variance, reasons.join('; ')])
|
||||
}
|
||||
|
||||
sendSuccess(res, { generated: achievementResult.rows.length, month })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 预算锁定 (T-113)
|
||||
// ============================================================
|
||||
|
||||
router.get('/budget-locks', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
|
||||
const result = await query(`SELECT * FROM analytics.v3_budget_lock WHERE lock_month = $1 ORDER BY store_code`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/budget-locks', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { lock_month, store_code, lock_reason, root_cause_analysis } = req.body
|
||||
const result = await query(`
|
||||
INSERT INTO analytics.v3_budget_lock (lock_month, store_code, lock_reason, root_cause_analysis)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (lock_month, store_code) DO UPDATE SET lock_reason = EXCLUDED.lock_reason, root_cause_analysis = EXCLUDED.root_cause_analysis
|
||||
RETURNING *
|
||||
`, [lock_month, store_code, lock_reason, root_cause_analysis])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.patch('/budget-locks/:id/approve', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { approval_status } = req.body
|
||||
await query(`UPDATE analytics.v3_budget_lock SET approval_status = $1, approved_by = $2 WHERE id = $3`,
|
||||
[approval_status, req.user?.name || 'system', req.params.id])
|
||||
sendSuccess(res, { updated: true })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user