Files
SBrainCO/server/src/routes/product.ts
T
freedakgmail d04e837d9c 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}对象格式
2026-08-05 20:09:39 +08:00

321 lines
16 KiB
TypeScript

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