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