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