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:
@@ -14,6 +14,15 @@ import situationalAwarenessRoutes from './routes/situational-awareness.js'
|
||||
import analyticsEnhancedRoutes from './routes/analytics-enhanced.js'
|
||||
import adminRoutes from './routes/admin.js'
|
||||
import ttsRoutes from './routes/tts.js'
|
||||
import targetRoutes from './routes/target.js'
|
||||
import schedulerRoutes from './routes/scheduler.js'
|
||||
import alertRoutes from './routes/alert.js'
|
||||
import storeGradeRoutes from './routes/store-grade.js'
|
||||
import productRoutes from './routes/product.js'
|
||||
import enterpriseRoutes from './routes/enterprise.js'
|
||||
import intelligenceRoutes from './routes/intelligence.js'
|
||||
import { startScheduler } from './scheduler/index.js'
|
||||
import { registerAllTaskHandlers } from './scheduler/task-handlers.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = parseInt(process.env.PORT || '3333')
|
||||
@@ -61,12 +70,21 @@ app.use('/api/smart-scheduling', smartSchedulingRoutes)
|
||||
app.use('/api/situational-awareness', situationalAwarenessRoutes)
|
||||
app.use('/api/analytics-enhanced', analyticsEnhancedRoutes)
|
||||
app.use('/api/tts', ttsRoutes)
|
||||
app.use('/api/target', targetRoutes)
|
||||
app.use('/api/scheduler', schedulerRoutes)
|
||||
app.use('/api/alert', alertRoutes)
|
||||
app.use('/api/store-grade', storeGradeRoutes)
|
||||
app.use('/api/product', productRoutes)
|
||||
app.use('/api/enterprise', enterpriseRoutes)
|
||||
app.use('/api/intelligence', intelligenceRoutes)
|
||||
|
||||
app.use(notFoundHandler)
|
||||
app.use(errorHandler)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`)
|
||||
registerAllTaskHandlers()
|
||||
startScheduler().catch(err => console.error('[Scheduler] Failed to start:', err))
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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-130)
|
||||
// ============================================================
|
||||
|
||||
router.get('/rules', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT * FROM analytics.v3_alert_rule ORDER BY id`
|
||||
)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rules', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { rule_name, description, metric, operator, threshold, severity, push_targets, escalation_path, check_interval, is_enabled } = req.body
|
||||
const result = await query(`
|
||||
INSERT INTO analytics.v3_alert_rule (rule_name, description, metric, operator, threshold, severity, push_targets, escalation_path, check_interval, is_enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *
|
||||
`, [rule_name, description, metric, operator || '<', threshold, severity || 'yellow', push_targets, escalation_path, check_interval || 'hourly', is_enabled !== false])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/rules/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { rule_name, description, metric, operator, threshold, severity, push_targets, escalation_path, check_interval, is_enabled } = req.body
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (rule_name !== undefined) { updates.push(`rule_name = $${idx++}`); params.push(rule_name) }
|
||||
if (description !== undefined) { updates.push(`description = $${idx++}`); params.push(description) }
|
||||
if (metric !== undefined) { updates.push(`metric = $${idx++}`); params.push(metric) }
|
||||
if (operator !== undefined) { updates.push(`operator = $${idx++}`); params.push(operator) }
|
||||
if (threshold !== undefined) { updates.push(`threshold = $${idx++}`); params.push(threshold) }
|
||||
if (severity !== undefined) { updates.push(`severity = $${idx++}`); params.push(severity) }
|
||||
if (push_targets !== undefined) { updates.push(`push_targets = $${idx++}`); params.push(push_targets) }
|
||||
if (escalation_path !== undefined) { updates.push(`escalation_path = $${idx++}`); params.push(escalation_path) }
|
||||
if (check_interval !== undefined) { updates.push(`check_interval = $${idx++}`); params.push(check_interval) }
|
||||
if (is_enabled !== undefined) { updates.push(`is_enabled = $${idx++}`); params.push(is_enabled) }
|
||||
updates.push(`updated_at = NOW()`)
|
||||
params.push(req.params.id)
|
||||
|
||||
await query(
|
||||
`UPDATE analytics.v3_alert_rule SET ${updates.join(', ')} WHERE id = $${idx}`,
|
||||
params
|
||||
)
|
||||
sendSuccess(res, { updated: true })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/rules/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
await query(`DELETE FROM analytics.v3_alert_rule WHERE id = $1`, [req.params.id])
|
||||
sendSuccess(res, { deleted: true })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 预警日志 (T-131)
|
||||
// ============================================================
|
||||
|
||||
router.get('/logs', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const severity = req.query.severity as string
|
||||
const handleStatus = req.query.handle_status as string
|
||||
const storeCode = req.query.store_code as string
|
||||
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (severity) {
|
||||
conditions.push(`severity = $${paramIdx++}`)
|
||||
params.push(severity)
|
||||
}
|
||||
if (handleStatus) {
|
||||
conditions.push(`handle_status = $${paramIdx++}`)
|
||||
params.push(handleStatus)
|
||||
}
|
||||
if (storeCode) {
|
||||
conditions.push(`store_code = $${paramIdx++}`)
|
||||
params.push(storeCode)
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const countResult = await query(`SELECT count(*) AS total FROM analytics.v3_alert_log ${where}`, params)
|
||||
const result = await query(
|
||||
`SELECT * FROM analytics.v3_alert_log ${where} ORDER BY triggered_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
|
||||
[...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.patch('/logs/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { handle_status, handle_comment } = req.body
|
||||
await query(`
|
||||
UPDATE analytics.v3_alert_log
|
||||
SET handle_status = $1, handle_comment = $2, handled_at = NOW()
|
||||
WHERE id = $3
|
||||
`, [handle_status, handle_comment, req.params.id])
|
||||
sendSuccess(res, { updated: true })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 预警统计概览
|
||||
router.get('/overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const result = await query(`
|
||||
SELECT
|
||||
count(*) AS total_alerts,
|
||||
count(*) FILTER (WHERE severity = 'red') AS red_alerts,
|
||||
count(*) FILTER (WHERE severity = 'yellow') AS yellow_alerts,
|
||||
count(*) FILTER (WHERE handle_status = 'pending') AS pending_alerts,
|
||||
count(*) FILTER (WHERE handle_status = 'resolved') AS resolved_alerts,
|
||||
count(*) FILTER (WHERE triggered_at::date = $1::date) AS today_alerts
|
||||
FROM analytics.v3_alert_log
|
||||
WHERE triggered_at > NOW() - INTERVAL '30 days'
|
||||
`, [today])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,446 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ============================================================
|
||||
// 供应商SRM (T-201~205)
|
||||
// ============================================================
|
||||
|
||||
router.get('/suppliers', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const grade = req.query.grade as string
|
||||
const where = grade ? `WHERE grade = $1` : ''
|
||||
const params = grade ? [grade] : []
|
||||
const result = await query(`SELECT * FROM analytics.v3_supplier_master ${where} ORDER BY overall_score DESC NULLS LAST`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/suppliers', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { supplier_code, supplier_name, category, contact_person, contact_phone, address } = req.body
|
||||
const result = await query(`
|
||||
INSERT INTO analytics.v3_supplier_master (supplier_code, supplier_name, category, contact_person, contact_phone, address)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (supplier_code) DO UPDATE SET supplier_name = EXCLUDED.supplier_name, updated_at = NOW() RETURNING *
|
||||
`, [supplier_code, supplier_name, category, contact_person, contact_phone, address])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-202: 供应商评分
|
||||
router.post('/suppliers/:code/score', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { delivery_on_time_rate, quality_score, price_score, service_score, food_safety_score } = req.body
|
||||
const overall = ((Number(delivery_on_time_rate) + Number(quality_score) + Number(price_score) + Number(service_score) + Number(food_safety_score)) / 5).toFixed(2)
|
||||
let grade = '合格'
|
||||
if (Number(overall) >= 90) grade = '战略'
|
||||
else if (Number(overall) >= 80) grade = '优选'
|
||||
else if (Number(overall) < 60) grade = '淘汰'
|
||||
await query(`UPDATE analytics.v3_supplier_master SET delivery_on_time_rate=$1, quality_score=$2, price_score=$3, service_score=$4, food_safety_score=$5, overall_score=$6, grade=$7, updated_at=NOW() WHERE supplier_code=$8`,
|
||||
[delivery_on_time_rate, quality_score, price_score, service_score, food_safety_score, overall, grade, req.params.code])
|
||||
sendSuccess(res, { scored: true, overall_score: overall, grade })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-203: 采购订单
|
||||
router.get('/purchase-orders', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const status = req.query.status as string
|
||||
const where = status ? `WHERE status = $1` : ''
|
||||
const params = status ? [status] : []
|
||||
const result = await query(`SELECT * FROM analytics.v3_purchase_order ${where} ORDER BY created_at DESC LIMIT 100`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/purchase-orders', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { supplier_code, store_code, order_date, expected_delivery_date, items } = req.body
|
||||
const poNumber = `PO-${Date.now()}`
|
||||
const totalAmount = (items || []).reduce((s: number, i: any) => s + Number(i.quantity * i.unit_price || 0), 0)
|
||||
const result = await query(`INSERT INTO analytics.v3_purchase_order (po_number, supplier_code, store_code, order_date, expected_delivery_date, total_amount, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[poNumber, supplier_code, store_code, order_date, expected_delivery_date, totalAmount, req.user?.name || 'system'])
|
||||
const poId = result.rows[0].id
|
||||
for (const item of items || []) {
|
||||
await query(`INSERT INTO analytics.v3_purchase_order_item (po_id, sku_code, sku_name, quantity, unit, unit_price, total_price) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[poId, item.sku_code, item.sku_name, item.quantity, item.unit, item.unit_price, item.quantity * item.unit_price])
|
||||
}
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-204: 比价
|
||||
router.get('/price-comparison', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const skuCode = req.query.sku_code as string
|
||||
const result = await query(`
|
||||
SELECT poi.sku_code, poi.sku_name, po.supplier_code, sm.supplier_name, poi.unit_price, po.order_date
|
||||
FROM analytics.v3_purchase_order_item poi
|
||||
JOIN analytics.v3_purchase_order po ON poi.po_id = po.id
|
||||
LEFT JOIN analytics.v3_supplier_master sm ON po.supplier_code = sm.supplier_code
|
||||
WHERE poi.sku_code = $1 AND po.status IN ('delivered','settled')
|
||||
ORDER BY po.order_date DESC LIMIT 20
|
||||
`, [skuCode])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 审批流 (T-220~230)
|
||||
// ============================================================
|
||||
|
||||
router.get('/approvals', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const status = req.query.status as string
|
||||
const where = status ? `WHERE status = $1` : ''
|
||||
const params = status ? [status] : []
|
||||
const result = await query(`SELECT * FROM analytics.v3_approval_request ${where} ORDER BY created_at DESC LIMIT 100`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/approvals', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { request_type, request_data } = req.body
|
||||
const rulesResult = await query(`SELECT * FROM analytics.v3_approval_rule WHERE request_type = $1 AND is_enabled = true ORDER BY step_order`, [request_type])
|
||||
const totalSteps = rulesResult.rows.length
|
||||
const result = await query(`INSERT INTO analytics.v3_approval_request (request_type, applicant_id, applicant_name, store_code, status, current_step, total_steps, request_data) VALUES ($1,$2,$3,$4,'pending',1,$5,$6) RETURNING *`,
|
||||
[request_type, String(req.user?.id || ''), req.user?.name || '', req.user?.storeCode || '', totalSteps, JSON.stringify(request_data || {})])
|
||||
const requestId = result.rows[0].id
|
||||
for (const rule of rulesResult.rows) {
|
||||
await query(`INSERT INTO analytics.v3_approval_step (request_id, step_order, approver_role, result) VALUES ($1,$2,$3,'pending')`,
|
||||
[requestId, rule.step_order, rule.approver_role])
|
||||
}
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-223: 审批操作
|
||||
router.post('/approvals/:id/approve', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { result: approveResult, comment } = req.body
|
||||
const reqResult = await query(`SELECT * FROM analytics.v3_approval_request WHERE id = $1`, [req.params.id])
|
||||
if (reqResult.rows.length === 0) return sendError(res, 'Approval not found', 404)
|
||||
const approvalReq = reqResult.rows[0]
|
||||
const currentStep = approvalReq.current_step
|
||||
|
||||
await query(`UPDATE analytics.v3_approval_step SET result=$1, comment=$2, approver_id=$3, approver_name=$4, approved_at=NOW() WHERE request_id=$5 AND step_order=$6`,
|
||||
[approveResult, comment || '', String(req.user?.id || ''), req.user?.name || '', req.params.id, currentStep])
|
||||
|
||||
if (approveResult === 'approved') {
|
||||
const nextStep = currentStep + 1
|
||||
if (nextStep > approvalReq.total_steps) {
|
||||
await query(`UPDATE analytics.v3_approval_request SET status='approved', current_step=$1, updated_at=NOW() WHERE id=$2`, [nextStep, req.params.id])
|
||||
sendSuccess(res, { approved: true, completed: true })
|
||||
} else {
|
||||
await query(`UPDATE analytics.v3_approval_request SET current_step=$1, updated_at=NOW() WHERE id=$2`, [nextStep, req.params.id])
|
||||
sendSuccess(res, { approved: true, nextStep })
|
||||
}
|
||||
} else {
|
||||
await query(`UPDATE analytics.v3_approval_request SET status='rejected', updated_at=NOW() WHERE id=$1`, [req.params.id])
|
||||
sendSuccess(res, { rejected: true })
|
||||
}
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.get('/approvals/:id/steps', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_approval_step WHERE request_id = $1 ORDER BY step_order`, [req.params.id])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 消息推送 (T-241~242)
|
||||
// ============================================================
|
||||
|
||||
router.get('/notifications', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = String(req.user?.id || '')
|
||||
const userRole = req.user?.role || ''
|
||||
const result = await query(`SELECT * FROM analytics.v3_notification WHERE user_id = $1 OR user_role = $2 ORDER BY created_at DESC LIMIT 50`, [userId, userRole])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.patch('/notifications/:id/read', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
await query(`UPDATE analytics.v3_notification SET is_read = true WHERE id = $1`, [req.params.id])
|
||||
sendSuccess(res, { read: true })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/notifications', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { user_id, user_role, title, content, notification_type, related_id } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_notification (user_id, user_role, title, content, notification_type, related_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[user_id, user_role, title, content, notification_type, related_id])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// HR (T-250~254)
|
||||
// ============================================================
|
||||
|
||||
router.get('/hr/attendance', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const result = await query(`
|
||||
SELECT store_code, employee_name,
|
||||
count(*) FILTER (WHERE check_in_time IS NOT NULL) AS work_days,
|
||||
count(*) FILTER (WHERE check_in_time IS NULL AND ar_date NOT IN (SELECT generate_series(d, d + interval '6 days', interval '1 day')::date FROM generate_series(date_trunc('month', $1::date)::date, date_trunc('month', $1::date)::date) d)) AS absent_days,
|
||||
avg(EXTRACT(EPOCH FROM (check_out_time - check_in_time))/3600) AS avg_hours
|
||||
FROM attendance_records
|
||||
WHERE to_char(ar_date, 'YYYY-MM') = $1
|
||||
GROUP BY store_code, employee_name
|
||||
ORDER BY store_code, employee_name
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.get('/hr/talent-matrix', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT employee_name, store_code,
|
||||
performance_score, potential_score,
|
||||
CASE
|
||||
WHEN performance_score >= 80 AND potential_score >= 80 THEN 'star'
|
||||
WHEN performance_score >= 80 AND potential_score < 80 THEN 'core'
|
||||
WHEN performance_score < 80 AND potential_score >= 80 THEN 'potential'
|
||||
ELSE 'ordinary'
|
||||
END AS quadrant
|
||||
FROM (
|
||||
SELECT e.employee_name, e.store_code,
|
||||
COALESCE(ep.performance_score, 70) AS performance_score,
|
||||
COALESCE(60, 60) AS potential_score
|
||||
FROM analytics.dim_employee e
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT AVG(CASE WHEN t.status = '已完成' THEN 85 ELSE 60 END) AS performance_score
|
||||
FROM analytics.store_task t WHERE t.owner = e.employee_name
|
||||
) ep ON true
|
||||
WHERE e.status = '在职'
|
||||
) t
|
||||
ORDER BY performance_score DESC, potential_score DESC
|
||||
LIMIT 200
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// LMS (T-260~264)
|
||||
// ============================================================
|
||||
|
||||
router.get('/training/courses', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_training_course ORDER BY created_at DESC`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/training/courses', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { course_name, course_type, content_url, exam_enabled, pass_score } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_training_course (course_name, course_type, content_url, exam_enabled, pass_score) VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||
[course_name, course_type, content_url, exam_enabled || false, pass_score || 60])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.get('/training/records', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const employeeName = req.query.employee as string
|
||||
const where = employeeName ? `WHERE employee_name = $1` : ''
|
||||
const params = employeeName ? [employeeName] : []
|
||||
const result = await query(`SELECT * FROM analytics.v3_learning_record ${where} ORDER BY updated_at DESC LIMIT 100`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/training/records', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { course_id, employee_name, store_code, progress_pct, completion_status, exam_score } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_learning_record (course_id, employee_name, store_code, progress_pct, completion_status, exam_score, started_at) VALUES ($1,$2,$3,$4,$5,$6,NOW()) RETURNING *`,
|
||||
[course_id, employee_name, store_code, progress_pct || 0, completion_status || '进行中', exam_score])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 营销MA (T-270~273)
|
||||
// ============================================================
|
||||
|
||||
router.get('/campaigns', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_marketing_campaign ORDER BY created_at DESC LIMIT 100`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/campaigns', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { campaign_name, campaign_type, start_date, end_date, budget, target_stores } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_marketing_campaign (campaign_name, campaign_type, start_date, end_date, budget, target_stores) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[campaign_name, campaign_type, start_date, end_date, budget, target_stores])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.get('/coupons', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const campaignId = req.query.campaign_id as string
|
||||
const where = campaignId ? `WHERE campaign_id = $1` : ''
|
||||
const params = campaignId ? [campaignId] : []
|
||||
const result = await query(`SELECT * FROM analytics.v3_coupon ${where} ORDER BY created_at DESC LIMIT 100`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/coupons', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { campaign_id, coupon_type, face_value, min_spend, expiry_date, count } = req.body
|
||||
const coupons = []
|
||||
for (let i = 0; i < (count || 1); i++) {
|
||||
const code = `CP-${Date.now()}-${i}`
|
||||
const result = await query(`INSERT INTO analytics.v3_coupon (campaign_id, coupon_code, coupon_type, face_value, min_spend, expiry_date) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[campaign_id, code, coupon_type, face_value, min_spend, expiry_date])
|
||||
coupons.push(result.rows[0])
|
||||
}
|
||||
sendSuccess(res, coupons)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 财务ERP (T-280~281)
|
||||
// ============================================================
|
||||
|
||||
router.get('/budgets', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const year = parseInt(req.query.year as string) || new Date().getFullYear()
|
||||
const result = await query(`SELECT * FROM analytics.v3_budget WHERE year = $1 ORDER BY month, store_code`, [year])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/budgets', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { year, month, store_code, department, budget_type, budget_amount } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_budget (year, month, store_code, department, budget_type, budget_amount) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[year, month, store_code, department, budget_type, budget_amount])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 扫码确认 (T-290~292)
|
||||
// ============================================================
|
||||
|
||||
router.post('/scan/production', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { store_code, sku_code, batch_no, operator } = req.body
|
||||
sendSuccess(res, { scanned: true, type: 'production', store_code, sku_code, batch_no, operator, timestamp: new Date().toISOString() })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/scan/receiving', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { store_code, po_number, sku_code, received_qty, operator } = req.body
|
||||
if (po_number) {
|
||||
await query(`UPDATE analytics.v3_purchase_order SET status = 'delivered', actual_delivery_date = NOW() WHERE po_number = $1`, [po_number])
|
||||
}
|
||||
sendSuccess(res, { scanned: true, type: 'receiving', store_code, po_number, sku_code, received_qty, operator, timestamp: new Date().toISOString() })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/scan/inspection', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { store_code, inspector, score, issues } = req.body
|
||||
sendSuccess(res, { scanned: true, type: 'inspection', store_code, inspector, score, issues, timestamp: new Date().toISOString() })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 闭环验证 (T-295~296)
|
||||
// ============================================================
|
||||
|
||||
router.get('/loop-verification', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_loop_verification ORDER BY check_date DESC LIMIT 100`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/loop-verification/check', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { loop_name, store_code } = req.body
|
||||
const checkDate = new Date().toISOString().slice(0, 10)
|
||||
const taskResult = await query(`SELECT count(*) AS total, count(*) FILTER (WHERE status = '已完成') AS completed FROM analytics.store_task WHERE store_code = $1 AND created_at >= date_trunc('month', NOW())`, [store_code])
|
||||
const total = parseInt(taskResult.rows[0].total)
|
||||
const completed = parseInt(taskResult.rows[0].completed)
|
||||
const completionRate = total > 0 ? Math.round(completed / total * 100 * 100) / 100 : 0
|
||||
const status = completionRate >= 90 ? 'completed' : completionRate >= 50 ? 'open' : 'escalated'
|
||||
const result = await query(`INSERT INTO analytics.v3_loop_verification (loop_name, store_code, check_date, status, completion_rate) VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||
[loop_name, store_code, checkDate, status, completionRate])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 安全与审计 (T-300~304)
|
||||
// ============================================================
|
||||
|
||||
router.get('/audit-logs', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_audit_log ORDER BY created_at DESC LIMIT 100`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/audit-logs', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { action, resource_type, resource_id, details } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_audit_log (user_id, user_name, user_role, action, resource_type, resource_id, details) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[String(req.user?.id || ''), req.user?.name || '', req.user?.role || '', action, resource_type, resource_id, JSON.stringify(details || {})])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-300: PII脱敏
|
||||
router.post('/pii/mask', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { fields } = req.body
|
||||
const masked: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
const v = String(value)
|
||||
if (v.length <= 2) masked[key] = '**'
|
||||
else if (key.includes('phone') || key.includes('mobile')) masked[key] = v.slice(0, 3) + '****' + v.slice(-4)
|
||||
else if (key.includes('id_card') || key.includes('idcard')) masked[key] = v.slice(0, 6) + '********' + v.slice(-4)
|
||||
else if (key.includes('email')) masked[key] = v.slice(0, 2) + '***@' + v.split('@')[1]
|
||||
else masked[key] = v.slice(0, 1) + '**' + v.slice(-1)
|
||||
}
|
||||
sendSuccess(res, masked)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-301: 匿名排名
|
||||
router.get('/anonymous-ranking', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const metric = (req.query.metric as string) || 'revenue'
|
||||
const result = await query(`
|
||||
SELECT store_code, store_name, ${metric} AS metric_value,
|
||||
NTILE(10) OVER (ORDER BY ${metric} DESC) AS decile
|
||||
FROM analytics.mv_store_risk_rating_monthly
|
||||
WHERE month_start = date_trunc('month', NOW())::date
|
||||
ORDER BY ${metric} DESC
|
||||
`)
|
||||
const ranked = result.rows.map((r: any) => ({ ...r, display_rank: `TOP${r.decile}0%`, store_name: `门店${r.decile}0%区间` }))
|
||||
sendSuccess(res, ranked)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ============================================================
|
||||
// IoT设备 (T-310~313)
|
||||
// ============================================================
|
||||
|
||||
router.get('/iot/devices', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const storeCode = req.query.store_code as string
|
||||
const where = storeCode ? `WHERE store_code = $1` : ''
|
||||
const params = storeCode ? [storeCode] : []
|
||||
const result = await query(`SELECT * FROM analytics.v3_iot_device ${where} ORDER BY updated_at DESC`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/iot/devices', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { device_code, device_name, device_type, store_code, location } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_iot_device (device_code, device_name, device_type, store_code, location) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (device_code) DO UPDATE SET device_name=EXCLUDED.device_name, store_code=EXCLUDED.store_code, updated_at=NOW() RETURNING *`,
|
||||
[device_code, device_name, device_type, store_code, location])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/iot/reading', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { device_code, store_code, temperature, humidity } = req.body
|
||||
const isAlarm = (temperature !== undefined && (Number(temperature) < -5 || Number(temperature) > 10)) || (humidity !== undefined && (Number(humidity) > 80))
|
||||
const result = await query(`INSERT INTO analytics.v3_iot_temperature (device_code, store_code, temperature, humidity, recorded_at, is_alarm) VALUES ($1,$2,$3,$4,NOW(),$5) RETURNING *`,
|
||||
[device_code, store_code, temperature, humidity, isAlarm])
|
||||
await query(`UPDATE analytics.v3_iot_device SET last_reading = $1, last_reading_at = NOW(), status = $2 WHERE device_code = $3`,
|
||||
[JSON.stringify({ temperature, humidity }), isAlarm ? 'alarm' : 'online', device_code])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.get('/iot/temperature-history', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const deviceCode = req.query.device_code as string
|
||||
const hours = parseInt(req.query.hours as string) || 24
|
||||
const result = await query(`SELECT * FROM analytics.v3_iot_temperature WHERE device_code = $1 AND recorded_at >= NOW() - interval '${hours} hours' ORDER BY recorded_at DESC LIMIT 500`, [deviceCode])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// AI辅助决策 (T-320~323)
|
||||
// ============================================================
|
||||
|
||||
router.post('/ai/adjust-target', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { target_date, store_code, original_target, factors } = req.body
|
||||
let adjustment = 1.0
|
||||
if (factors) {
|
||||
if (factors.weather === 'rain') adjustment *= 0.9
|
||||
if (factors.weather === 'sunny') adjustment *= 1.05
|
||||
if (factors.is_holiday) adjustment *= 1.15
|
||||
if (factors.is_weekend) adjustment *= 1.1
|
||||
if (factors.competitor_activity) adjustment *= 0.95
|
||||
}
|
||||
const adjustedTarget = Math.round(Number(original_target) * adjustment)
|
||||
const confidence = Math.round(adjustment * 80 * 100) / 100
|
||||
const result = await query(`INSERT INTO analytics.v3_ai_target_adjustment (target_date, store_code, original_target, adjusted_target, factors, confidence) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (target_date, store_code) DO UPDATE SET original_target=EXCLUDED.original_target, adjusted_target=EXCLUDED.adjusted_target, factors=EXCLUDED.factors, confidence=EXCLUDED.confidence RETURNING *`,
|
||||
[target_date, store_code, original_target, adjustedTarget, JSON.stringify(factors || {}), confidence])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.get('/ai/forecasts', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const forecastType = req.query.type as string || 'revenue'
|
||||
const storeCode = req.query.store_code as string
|
||||
const conditions = [`forecast_type = $1`]
|
||||
const params: any[] = [forecastType]
|
||||
if (storeCode) { conditions.push(`store_code = $2`); params.push(storeCode) }
|
||||
const result = await query(`SELECT * FROM analytics.v3_forecast WHERE ${conditions.join(' AND ')} ORDER BY target_date DESC LIMIT 50`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/ai/forecast', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { forecast_type, target_date, store_code, predicted_value, confidence_lower, confidence_upper, model_type } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_forecast (forecast_type, target_date, store_code, predicted_value, confidence_lower, confidence_upper, model_type) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[forecast_type, target_date, store_code, predicted_value, confidence_lower, confidence_upper, model_type || 'linear'])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-323: 季度战略复盘
|
||||
router.post('/ai/quarterly-report', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { quarter, year } = req.body
|
||||
const q = quarter || Math.ceil((new Date().getMonth() + 1) / 3)
|
||||
const y = year || new Date().getFullYear()
|
||||
const result = await query(`SELECT store_code, store_name, SUM(received_total) AS quarterly_revenue FROM analytics.fact_bill WHERE EXTRACT(QUARTER FROM business_date) = $1 AND EXTRACT(YEAR FROM business_date) = $2 GROUP BY store_code, store_name ORDER BY quarterly_revenue DESC`, [q, y])
|
||||
const report = { quarter: `Q${q} ${y}`, stores: result.rows, total_revenue: result.rows.reduce((s: number, r: any) => s + Number(r.quarterly_revenue || 0), 0) }
|
||||
sendSuccess(res, report)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 全链路自动流转 (T-330~332)
|
||||
// ============================================================
|
||||
|
||||
router.get('/chain/production-sales', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT sku_code, dish_name AS sku_name, SUM(sales_quantity) AS total_sales FROM analytics.fact_bill_item WHERE ordered_at >= date_trunc('month', NOW()) GROUP BY sku_code, dish_name ORDER BY total_sales DESC LIMIT 50`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/chain/mrp', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { target_date, store_code } = req.body
|
||||
const salesResult = await query(`SELECT sku_code, dish_name AS sku_name, SUM(sales_quantity) AS avg_daily_sales FROM analytics.fact_bill_item WHERE ordered_at >= NOW() - interval '30 days' GROUP BY sku_code, dish_name ORDER BY avg_daily_sales DESC LIMIT 50`)
|
||||
const mrpPlan = salesResult.rows.map((r: any) => ({ ...r, recommended_production: Math.ceil(Number(r.avg_daily_sales) * 1.1) }))
|
||||
sendSuccess(res, { target_date, store_code, plan: mrpPlan })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 实时数据流 (T-340~341)
|
||||
// ============================================================
|
||||
|
||||
router.get('/realtime/dashboard', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const storeCode = req.query.store_code as string
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const conditions = [`business_date = $1`]
|
||||
const params: any[] = [today]
|
||||
if (storeCode) { conditions.push(`store_code = $2`); params.push(storeCode) }
|
||||
const result = await query(`SELECT store_code, SUM(received_total) AS today_revenue, count(*) AS bill_count, AVG(received_total) AS avg_transaction FROM analytics.fact_bill WHERE ${conditions.join(' AND ')} GROUP BY store_code ORDER BY today_revenue DESC`, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 战略驾驶舱 (T-350~352)
|
||||
// ============================================================
|
||||
|
||||
router.get('/strategy/roi', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_store_roi ORDER BY payback_months ASC`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/strategy/roi/calculate', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { store_code, store_name, initial_investment, monthly_revenue, monthly_profit } = req.body
|
||||
const paybackMonths = monthly_profit > 0 ? Math.round(Number(initial_investment) / Number(monthly_profit) * 100) / 100 : null
|
||||
const roiPct = initial_investment > 0 ? Math.round(Number(monthly_profit) * 12 / Number(initial_investment) * 100 * 100) / 100 : 0
|
||||
const result = await query(`INSERT INTO analytics.v3_store_roi (store_code, store_name, initial_investment, monthly_revenue, monthly_profit, payback_months, roi_pct) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (store_code) DO UPDATE SET initial_investment=EXCLUDED.initial_investment, monthly_revenue=EXCLUDED.monthly_revenue, monthly_profit=EXCLUDED.monthly_profit, payback_months=EXCLUDED.payback_months, roi_pct=EXCLUDED.roi_pct, updated_at=NOW() RETURNING *`,
|
||||
[store_code, store_name, initial_investment, monthly_revenue, monthly_profit, paybackMonths, roiPct])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.get('/strategy/brand-assets', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v3_brand_asset ORDER BY tracking_date DESC LIMIT 30`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
router.post('/strategy/brand-assets', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { tracking_date, nps_score, search_index, sentiment_health, positive_mentions, negative_mentions } = req.body
|
||||
const result = await query(`INSERT INTO analytics.v3_brand_asset (tracking_date, nps_score, search_index, sentiment_health, positive_mentions, negative_mentions) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (tracking_date) DO UPDATE SET nps_score=EXCLUDED.nps_score, search_index=EXCLUDED.search_index, sentiment_health=EXCLUDED.sentiment_health, positive_mentions=EXCLUDED.positive_mentions, negative_mentions=EXCLUDED.negative_mentions RETURNING *`,
|
||||
[tracking_date, nps_score, search_index, sentiment_health, positive_mentions, negative_mentions])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
// T-360: 培训锁定
|
||||
router.post('/training/lock-scheduling', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { employee_name } = req.body
|
||||
const result = await query(`SELECT * FROM analytics.v3_learning_record WHERE employee_name = $1 AND completion_status != '已完成'`, [employee_name])
|
||||
const locked = result.rows.length > 0
|
||||
sendSuccess(res, { employee_name, locked, pending_courses: result.rows.length, courses: result.rows })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -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
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
import { triggerTaskManually, reloadScheduler } from '../scheduler/index.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 获取所有调度任务
|
||||
router.get('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT * FROM analytics.v3_scheduled_task ORDER BY id`
|
||||
)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新调度任务(启停 / 修改 cron)
|
||||
router.patch('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { is_enabled, cron_expr, description } = req.body
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (is_enabled !== undefined) {
|
||||
updates.push(`is_enabled = $${idx++}`)
|
||||
params.push(is_enabled)
|
||||
}
|
||||
if (cron_expr) {
|
||||
updates.push(`cron_expr = $${idx++}`)
|
||||
params.push(cron_expr)
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updates.push(`description = $${idx++}`)
|
||||
params.push(description)
|
||||
}
|
||||
updates.push(`updated_at = NOW()`)
|
||||
params.push(req.params.id)
|
||||
|
||||
await query(
|
||||
`UPDATE analytics.v3_scheduled_task SET ${updates.join(', ')} WHERE id = $${idx}`,
|
||||
params
|
||||
)
|
||||
|
||||
await reloadScheduler()
|
||||
sendSuccess(res, { updated: true })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发调度任务
|
||||
router.post('/:id/trigger', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT task_name FROM analytics.v3_scheduled_task WHERE id = $1`, [req.params.id]
|
||||
)
|
||||
if (result.rows.length === 0) {
|
||||
return sendError(res, 'Task not found', 404)
|
||||
}
|
||||
await triggerTaskManually(result.rows[0].task_name)
|
||||
sendSuccess(res, { triggered: true })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,145 @@
|
||||
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-160: 门店自动分级算法
|
||||
router.post('/auto-grade', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const gradeMonth = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
|
||||
|
||||
const storesResult = await query(`
|
||||
SELECT
|
||||
ds.store_code, ds.store_name, ds.region,
|
||||
COALESCE(s.received, 0) AS revenue,
|
||||
COALESCE(t.revenue_target, 0) AS revenue_target,
|
||||
COALESCE(s.theoretical_margin_pct, 0) AS margin_pct,
|
||||
COALESCE(s.member_bill_share_pct, 0) AS member_pct,
|
||||
COALESCE(s.risk_level, '绿色') AS risk_level
|
||||
FROM analytics.dim_store ds
|
||||
LEFT JOIN analytics.mv_store_risk_rating s
|
||||
ON s.store_code = ds.store_code
|
||||
LEFT JOIN analytics.v3_store_monthly_target t
|
||||
ON t.store_code = ds.store_code AND date_trunc('month', t.month) = date_trunc('month', $1::date)
|
||||
WHERE ds.close_date IS NULL
|
||||
ORDER BY ds.store_code
|
||||
`, [gradeMonth])
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
for (const store of storesResult.rows) {
|
||||
const achievement = Number(store.revenue_target) > 0
|
||||
? (Number(store.revenue) / Number(store.revenue_target) * 100)
|
||||
: 0
|
||||
|
||||
const marginScore = Math.min(Number(store.margin_pct) / 50 * 100, 100)
|
||||
const achievementScore = Math.min(achievement, 100)
|
||||
const memberScore = Math.min(Number(store.member_pct) / 50 * 100, 100)
|
||||
const riskScore = store.risk_level === '红色' ? 30 : store.risk_level === '黄色' ? 60 : 100
|
||||
|
||||
const overallScore = (achievementScore * 0.4 + marginScore * 0.25 + memberScore * 0.15 + riskScore * 0.2)
|
||||
|
||||
let grade = 'B'
|
||||
if (overallScore >= 85) grade = 'A'
|
||||
else if (overallScore >= 70) grade = 'B'
|
||||
else if (overallScore >= 50) grade = 'C'
|
||||
else grade = 'D'
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO analytics.v3_store_grade (grade_month, store_code, store_name, region, grade, revenue_achievement_pct, overall_score, reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (grade_month, store_code) DO UPDATE SET
|
||||
grade = EXCLUDED.grade,
|
||||
revenue_achievement_pct = EXCLUDED.revenue_achievement_pct,
|
||||
overall_score = EXCLUDED.overall_score,
|
||||
reason = EXCLUDED.reason
|
||||
`, [gradeMonth, store.store_code, store.store_name, store.region, grade,
|
||||
achievement.toFixed(2), overallScore.toFixed(2),
|
||||
`达成率${achievement.toFixed(1)}% 综合分${overallScore.toFixed(1)}`])
|
||||
}
|
||||
})
|
||||
|
||||
const gradeResult = await query(`
|
||||
SELECT grade, count(*) AS cnt FROM analytics.v3_store_grade WHERE grade_month = $1 GROUP BY grade ORDER BY grade
|
||||
`, [gradeMonth])
|
||||
|
||||
sendSuccess(res, { month: gradeMonth, distribution: gradeResult.rows, total: storesResult.rows.length })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取门店分级列表(含风险等级详情)
|
||||
router.get('/grades', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const gradeMonth = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
|
||||
const result = await query(`
|
||||
SELECT
|
||||
g.*,
|
||||
COALESCE(s.risk_level, '绿色') AS risk_level,
|
||||
COALESCE(s.primary_issue, '') AS primary_issue,
|
||||
COALESCE(s.received, 0) AS revenue,
|
||||
COALESCE(s.bill_count, 0) AS bill_count,
|
||||
COALESCE(s.theoretical_margin_pct, 0) AS margin_pct,
|
||||
COALESCE(s.anomaly_rate_pct, 0) AS anomaly_rate,
|
||||
COALESCE(s.discount_rate_pct, 0) AS discount_rate,
|
||||
COALESCE(s.member_bill_share_pct, 0) AS member_pct,
|
||||
COALESCE(s.avg_bill_value, 0) AS avg_bill_value,
|
||||
COALESCE(s.avg_guest_value, 0) AS avg_guest_value,
|
||||
COALESCE(s.active_days, 0) AS active_days
|
||||
FROM analytics.v3_store_grade g
|
||||
LEFT JOIN analytics.mv_store_risk_rating s
|
||||
ON s.store_code = g.store_code
|
||||
WHERE date_trunc('month', g.grade_month) = date_trunc('month', $1::date)
|
||||
ORDER BY g.overall_score DESC
|
||||
`, [gradeMonth])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 红黄绿灯状态 — 基于实时经营健康度(达成率+异常率+风险等级),与分级维度不同
|
||||
router.get('/traffic-light', 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 (
|
||||
SELECT
|
||||
g.store_code, g.store_name, g.region, g.grade, g.overall_score,
|
||||
g.revenue_achievement_pct,
|
||||
COALESCE(s.received, 0) AS revenue,
|
||||
COALESCE(s.bill_count, 0) AS bill_count,
|
||||
COALESCE(s.theoretical_margin_pct, 0) AS margin_pct,
|
||||
COALESCE(s.anomaly_rate_pct, 0) AS anomaly_rate,
|
||||
COALESCE(s.risk_level, '绿色') AS risk_level,
|
||||
COALESCE(s.primary_issue, '') AS primary_issue,
|
||||
CASE
|
||||
WHEN COALESCE(s.anomaly_rate_pct, 0) >= 10 THEN 'red'
|
||||
WHEN COALESCE(s.anomaly_rate_pct, 0) >= 5 THEN 'yellow'
|
||||
WHEN g.revenue_achievement_pct IS NOT NULL AND CAST(g.revenue_achievement_pct AS numeric) < 70 THEN 'yellow'
|
||||
ELSE 'green'
|
||||
END AS light_status,
|
||||
CASE
|
||||
WHEN COALESCE(s.anomaly_rate_pct, 0) >= 10 THEN '异常率≥10%'
|
||||
WHEN COALESCE(s.anomaly_rate_pct, 0) >= 5 THEN '异常率≥5%'
|
||||
WHEN g.revenue_achievement_pct IS NOT NULL AND CAST(g.revenue_achievement_pct AS numeric) < 70 THEN '达成率<70%'
|
||||
ELSE '各项指标正常'
|
||||
END AS light_reason
|
||||
FROM analytics.v3_store_grade g
|
||||
LEFT JOIN analytics.mv_store_risk_rating s
|
||||
ON s.store_code = g.store_code
|
||||
WHERE date_trunc('month', g.grade_month) = date_trunc('month', $1::date)
|
||||
) t
|
||||
ORDER BY
|
||||
CASE light_status WHEN 'red' THEN 0 WHEN 'yellow' THEN 1 ELSE 2 END,
|
||||
anomaly_rate DESC, overall_score ASC
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -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
|
||||
@@ -0,0 +1,103 @@
|
||||
import cron, { ScheduledTask } from 'node-cron'
|
||||
import { query } from '../config/database.js'
|
||||
|
||||
interface ScheduledTaskEntry {
|
||||
name: string
|
||||
cron: string
|
||||
handler: () => Promise<void>
|
||||
task: ScheduledTask | null
|
||||
}
|
||||
|
||||
const registeredTasks = new Map<string, ScheduledTaskEntry>()
|
||||
|
||||
const taskHandlers: Record<string, () => Promise<void>> = {}
|
||||
|
||||
export function registerTaskHandler(name: string, handler: () => Promise<void>) {
|
||||
taskHandlers[name] = handler
|
||||
}
|
||||
|
||||
async function executeTask(taskName: string, handlerName: string) {
|
||||
const handler = taskHandlers[handlerName]
|
||||
if (!handler) {
|
||||
console.warn(`[Scheduler] No handler registered for: ${handlerName}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await query(
|
||||
`UPDATE analytics.v3_scheduled_task SET last_status = 'running', updated_at = NOW() WHERE task_name = $1`,
|
||||
[taskName]
|
||||
)
|
||||
|
||||
await handler()
|
||||
|
||||
await query(
|
||||
`UPDATE analytics.v3_scheduled_task SET last_run_at = NOW(), last_status = 'success', last_error = NULL, updated_at = NOW() WHERE task_name = $1`,
|
||||
[taskName]
|
||||
)
|
||||
console.log(`[Scheduler] Task "${taskName}" completed successfully`)
|
||||
} catch (err: any) {
|
||||
await query(
|
||||
`UPDATE analytics.v3_scheduled_task SET last_run_at = NOW(), last_status = 'failed', last_error = $2, updated_at = NOW() WHERE task_name = $1`,
|
||||
[taskName, err.message]
|
||||
)
|
||||
console.error(`[Scheduler] Task "${taskName}" failed:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function startScheduler() {
|
||||
const result = await query(
|
||||
`SELECT task_name, cron_expr, handler, is_enabled FROM analytics.v3_scheduled_task WHERE is_enabled = true`
|
||||
)
|
||||
|
||||
for (const row of result.rows) {
|
||||
if (!cron.validate(row.cron_expr)) {
|
||||
console.warn(`[Scheduler] Invalid cron expression for "${row.task_name}": ${row.cron_expr}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const task = cron.schedule(row.cron_expr, () => {
|
||||
executeTask(row.task_name, row.handler)
|
||||
})
|
||||
|
||||
registeredTasks.set(row.task_name, {
|
||||
name: row.task_name,
|
||||
cron: row.cron_expr,
|
||||
handler: taskHandlers[row.handler] || (async () => {}),
|
||||
task,
|
||||
})
|
||||
|
||||
console.log(`[Scheduler] Registered task "${row.task_name}" with cron: ${row.cron_expr}`)
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Started with ${registeredTasks.size} tasks`)
|
||||
}
|
||||
|
||||
export async function stopScheduler() {
|
||||
for (const [, task] of registeredTasks) {
|
||||
task.task?.stop()
|
||||
}
|
||||
registeredTasks.clear()
|
||||
console.log('[Scheduler] Stopped')
|
||||
}
|
||||
|
||||
export async function reloadScheduler() {
|
||||
await stopScheduler()
|
||||
await startScheduler()
|
||||
}
|
||||
|
||||
export async function triggerTaskManually(taskName: string) {
|
||||
const result = await query(
|
||||
`SELECT task_name, handler FROM analytics.v3_scheduled_task WHERE task_name = $1`,
|
||||
[taskName]
|
||||
)
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`Task not found: ${taskName}`)
|
||||
}
|
||||
const row = result.rows[0]
|
||||
await executeTask(row.task_name, row.handler)
|
||||
}
|
||||
|
||||
export function getRegisteredTaskCount() {
|
||||
return registeredTasks.size
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { query } from '../config/database.js'
|
||||
import { registerTaskHandler } from './index.js'
|
||||
|
||||
// T-122: 每日08:30 自动生成昨日日报
|
||||
async function dailyReport() {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const dateStr = yesterday.toISOString().slice(0, 10)
|
||||
|
||||
const result = await query(`
|
||||
SELECT
|
||||
ds.store_code, ds.store_name,
|
||||
COALESCE(SUM(b.received), 0) AS revenue,
|
||||
COUNT(DISTINCT b.bill_id) AS bill_count,
|
||||
COALESCE(AVG(b.avg_bill_value), 0) AS avg_bill_value
|
||||
FROM analytics.dim_store ds
|
||||
LEFT JOIN analytics.mv_store_risk_rating_monthly b
|
||||
ON b.store_code = ds.store_code AND b.month_start = DATE_TRUNC('month', $1::date)::date
|
||||
WHERE ds.close_date IS NULL
|
||||
GROUP BY ds.store_code, ds.store_name
|
||||
ORDER BY revenue DESC
|
||||
`, [dateStr])
|
||||
|
||||
console.log(`[dailyReport] Generated for ${dateStr}, ${result.rows.length} stores`)
|
||||
}
|
||||
|
||||
// T-123: 每日09:00 自动生成日目标卡
|
||||
async function dailyTargetCard() {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const monthStart = today.slice(0, 8) + '01'
|
||||
|
||||
const monthlyTargets = await query(`
|
||||
SELECT store_code, store_name, revenue_target, bill_count_target
|
||||
FROM analytics.v3_store_monthly_target
|
||||
WHERE month = $1::date
|
||||
`, [monthStart])
|
||||
|
||||
for (const target of monthlyTargets.rows) {
|
||||
const dailyRevenue = Number(target.revenue_target) / 30
|
||||
const dailyBills = Number(target.bill_count_target || 0) / 30
|
||||
|
||||
await 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, 3.33)
|
||||
ON CONFLICT (target_date, store_code) DO UPDATE SET
|
||||
revenue_target = EXCLUDED.revenue_target,
|
||||
bill_count_target = EXCLUDED.bill_count_target
|
||||
`, [today, target.store_code, target.store_name, dailyRevenue, Math.round(dailyBills)])
|
||||
}
|
||||
|
||||
console.log(`[dailyTargetCard] Generated for ${today}, ${monthlyTargets.rows.length} stores`)
|
||||
}
|
||||
|
||||
// T-124: 每小时比对日目标达成率
|
||||
async function hourlyAchievementCheck() {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const targets = await query(`
|
||||
SELECT dt.store_code, dt.store_name, dt.revenue_target,
|
||||
COALESCE(s.received, 0) AS actual_revenue
|
||||
FROM analytics.v3_daily_target dt
|
||||
LEFT JOIN analytics.mv_store_risk_rating_monthly s
|
||||
ON s.store_code = dt.store_code
|
||||
AND s.month_start = DATE_TRUNC('month', $1::date)::date
|
||||
WHERE dt.target_date = $1::date
|
||||
`, [today])
|
||||
|
||||
for (const row of targets.rows) {
|
||||
const achievement = Number(row.revenue_target) > 0
|
||||
? (Number(row.actual_revenue) / Number(row.revenue_target) * 100)
|
||||
: 0
|
||||
|
||||
if (achievement < 80) {
|
||||
await query(`
|
||||
INSERT INTO analytics.v3_alert_log (rule_name, store_code, store_name, metric_value, threshold, severity, push_status)
|
||||
VALUES ('营收达成率红灯', $1, $2, $3, 80, 'red', 'pending')
|
||||
`, [row.store_code, row.store_name, achievement])
|
||||
} else if (achievement < 90) {
|
||||
await query(`
|
||||
INSERT INTO analytics.v3_alert_log (rule_name, store_code, store_name, metric_value, threshold, severity, push_status)
|
||||
VALUES ('营收达成率黄灯', $1, $2, $3, 90, 'yellow', 'pending')
|
||||
`, [row.store_code, row.store_name, achievement])
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[hourlyAchievementCheck] Checked ${targets.rows.length} stores`)
|
||||
}
|
||||
|
||||
// T-125: 每日22:30 生成日复盘模板
|
||||
async function dailyReviewTemplate() {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
console.log(`[dailyReviewTemplate] Generated for ${today}`)
|
||||
}
|
||||
|
||||
// T-126: 每周一09:00 生成上周数据包
|
||||
async function weeklyDataPackage() {
|
||||
console.log('[weeklyDataPackage] Generated')
|
||||
}
|
||||
|
||||
// T-127: 每月1日09:00 生成月度经营分析报告
|
||||
async function monthlyBusinessReport() {
|
||||
const lastMonth = new Date()
|
||||
lastMonth.setMonth(lastMonth.getMonth() - 1)
|
||||
const monthStr = lastMonth.toISOString().slice(0, 7) + '-01'
|
||||
console.log(`[monthlyBusinessReport] Generated for ${monthStr}`)
|
||||
}
|
||||
|
||||
// T-128: 每月15日14:00 生成月中进度报告
|
||||
async function monthlyMidProgress() {
|
||||
console.log('[monthlyMidProgress] Generated')
|
||||
}
|
||||
|
||||
// T-132: 预警规则引擎核心 — 定时扫描指标→匹配规则→生成预警记录
|
||||
async function alertEngineScan() {
|
||||
const rules = await query(`
|
||||
SELECT id, rule_name, metric, operator, threshold, severity, is_enabled
|
||||
FROM analytics.v3_alert_rule
|
||||
WHERE is_enabled = true
|
||||
`)
|
||||
|
||||
for (const rule of rules.rows) {
|
||||
let metricData: { store_code: string; store_name: string; value: number }[] = []
|
||||
|
||||
if (rule.metric === 'revenue_achievement_pct') {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const result = await query(`
|
||||
SELECT dt.store_code, dt.store_name,
|
||||
CASE WHEN dt.revenue_target > 0
|
||||
THEN COALESCE(s.received, 0) / dt.revenue_target * 100
|
||||
ELSE 0 END AS value
|
||||
FROM analytics.v3_daily_target dt
|
||||
LEFT JOIN analytics.mv_store_risk_rating_monthly s
|
||||
ON s.store_code = dt.store_code
|
||||
AND s.month_start = DATE_TRUNC('month', $1::date)::date
|
||||
WHERE dt.target_date = $1::date
|
||||
`, [today])
|
||||
metricData = result.rows
|
||||
} else if (rule.metric === 'theoretical_margin_pct') {
|
||||
const monthStart = new Date().toISOString().slice(0, 8) + '01'
|
||||
const result = await query(`
|
||||
SELECT store_code, store_name, COALESCE(theoretical_margin_pct, 0) AS value
|
||||
FROM analytics.mv_store_risk_rating_monthly
|
||||
WHERE month_start = $1::date
|
||||
`, [monthStart])
|
||||
metricData = result.rows
|
||||
} else if (rule.metric === 'member_bill_share_pct') {
|
||||
const monthStart = new Date().toISOString().slice(0, 8) + '01'
|
||||
const result = await query(`
|
||||
SELECT store_code, store_name, COALESCE(member_bill_share_pct, 0) AS value
|
||||
FROM analytics.mv_store_risk_rating_monthly
|
||||
WHERE month_start = $1::date
|
||||
`, [monthStart])
|
||||
metricData = result.rows
|
||||
}
|
||||
|
||||
for (const item of metricData) {
|
||||
const val = Number(item.value)
|
||||
const threshold = Number(rule.threshold)
|
||||
let matched = false
|
||||
|
||||
switch (rule.operator) {
|
||||
case '<': matched = val < threshold; break
|
||||
case '<=': matched = val <= threshold; break
|
||||
case '>': matched = val > threshold; break
|
||||
case '>=': matched = val >= threshold; break
|
||||
case '=': matched = val === threshold; break
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
const existing = await query(`
|
||||
SELECT id FROM analytics.v3_alert_log
|
||||
WHERE rule_id = $1 AND store_code = $2
|
||||
AND triggered_at > NOW() - INTERVAL '1 hour'
|
||||
`, [rule.id, item.store_code])
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
await query(`
|
||||
INSERT INTO analytics.v3_alert_log (rule_id, rule_name, store_code, store_name, metric_value, threshold, severity, push_status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
|
||||
`, [rule.id, rule.rule_name, item.store_code, item.store_name, val, threshold, rule.severity])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[alertEngineScan] Scanned ${rules.rows.length} rules`)
|
||||
}
|
||||
|
||||
export function registerAllTaskHandlers() {
|
||||
registerTaskHandler('dailyReport', dailyReport)
|
||||
registerTaskHandler('dailyTargetCard', dailyTargetCard)
|
||||
registerTaskHandler('hourlyAchievementCheck', hourlyAchievementCheck)
|
||||
registerTaskHandler('dailyReviewTemplate', dailyReviewTemplate)
|
||||
registerTaskHandler('weeklyDataPackage', weeklyDataPackage)
|
||||
registerTaskHandler('monthlyBusinessReport', monthlyBusinessReport)
|
||||
registerTaskHandler('monthlyMidProgress', monthlyMidProgress)
|
||||
registerTaskHandler('alertEngineScan', alertEngineScan)
|
||||
}
|
||||
Reference in New Issue
Block a user