fix: P2/P3审计问题修复 + 更新审计文档移除已修复问题

- 修复schema前缀:确认central_kitchen/mv_distribution/mv_dish_sales等表在public schema,撤销错误的analytics.前缀
- 移除API内部物化视图刷新:mv_distribution_monthly/mv_dish_sales_monthly改为console.warn
- 修复BOM树节点key:使用path字段替代material_code+level确保唯一性
- 修复HR人才矩阵potential_score:基于v3_learning_record动态计算替代硬编码60
- 修复StoreDetailPage公司均值:优先使用API返回的companyAvg
- 修复前端字段名匹配:discount_rate_pct/check_comment等
- 添加explicit column list和LIMIT到大型查询
- 更新数据溯源审计.md:移除全部52个已修复/确认无需修复的问题条目
This commit is contained in:
freedakgmail
2026-08-11 22:00:58 +08:00
parent 915345bfd6
commit 8f5271fcc6
15 changed files with 3399 additions and 150 deletions
@@ -31,7 +31,7 @@ export function ExpenseOverviewTab({ month }: { month: string }) {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<CollapsibleSection title="费用结构分析" subtitle="2026年4月各费用科目占比">
<CollapsibleSection title="费用结构分析" subtitle={`${month}各费用科目占比`}>
<ResponsiveContainer width="100%" height={400}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={120} labelLine={false}>
@@ -116,7 +116,7 @@ function StoreDetailModal({ store, onClose }: { store: StoreDetail | null, onClo
{ label: '面积(㎡)', value: formatNumber(store.area_sqm) },
{ label: '坪效', value: formatNumber(store.received_per_sqm) },
{ label: '账单数', value: formatNumber(store.bill_count) },
{ label: '租约到期', value: store.lease_expiry_date ? new Date(store.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
{ label: '租约到期', value: store.lease_expiry_date ? String(store.lease_expiry_date).substring(0, 10) : '-' },
]
return (
@@ -197,9 +197,9 @@ export function LossDiagnosisTab({ month }: { month: string }) {
const rows = (data as any)?.data || []
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
const p0Count = rows.filter((r: any) => r.priority === 'P0').length
const p1Count = rows.filter((r: any) => r.priority === 'P1').length
const totalLoss = rows.reduce((sum: number, r: any) => sum + parseFloat(r.actual_store_contribution || 0), 0)
const p0Count = meta.evalStats ? Number(meta.evalStats.p0_count) : rows.filter((r: any) => r.priority === 'P0').length
const p1Count = meta.evalStats ? Number(meta.evalStats.p1_count) : rows.filter((r: any) => r.priority === 'P1').length
const totalLoss = meta.evalStats ? Number(meta.evalStats.total_loss) : rows.reduce((sum: number, r: any) => sum + parseFloat(r.actual_store_contribution || 0), 0)
return (
<div className="space-y-4">
@@ -76,7 +76,7 @@ export function RentRiskTab({ month }: { month: string }) {
{ key: 'rent_rate_pct', label: '租金率', align: 'right', render: (r) => <span className={parseFloat(r.rent_rate_pct) > 20 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.rent_rate_pct)}</span> },
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => formatNumber(r.area_sqm) },
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => formatNumber(r.received_per_sqm) },
{ key: 'lease_expiry_date', label: '租约到期', render: (r) => r.lease_expiry_date ? new Date(r.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
{ key: 'lease_expiry_date', label: '租约到期', render: (r) => r.lease_expiry_date ? String(r.lease_expiry_date).substring(0, 10) : '-' },
{ key: 'risk_level', label: '风险等级', render: (r) => <span className={RISK_COLORS[r.risk_level] || ''}>{r.risk_level}</span> },
{ key: 'suggestion', label: '建议' },
]}
@@ -59,7 +59,7 @@ function EvalDetailModal({ store, onClose }: { store: EvalStoreDetail | null, on
{ label: '租金率', value: formatPercent(store.rent_rate_pct), warn: parseFloat(store.rent_rate_pct) > 20 },
{ label: '坪效', value: formatNumber(store.received_per_sqm), warn: parseFloat(store.received_per_sqm) < 1000 },
{ label: '面积(㎡)', value: formatNumber(store.area_sqm) },
{ label: '租约到期', value: store.lease_expiry_date ? new Date(store.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
{ label: '租约到期', value: store.lease_expiry_date ? String(store.lease_expiry_date).substring(0, 10) : '-' },
]
return (
@@ -167,7 +167,7 @@ export function StoreEvaluationTab({ month }: { month: string }) {
{ key: 'actual_store_contribution', label: '贡献利润', align: 'right', render: (r) => <span className={parseFloat(r.actual_store_contribution) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.actual_store_contribution)}</span> },
{ key: 'actual_store_contribution_rate_pct', label: '贡献率', align: 'right', render: (r) => formatPercent(r.actual_store_contribution_rate_pct) },
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => formatNumber(r.received_per_sqm) },
{ key: 'lease_expiry_date', label: '租约到期', render: (r) => r.lease_expiry_date ? new Date(r.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
{ key: 'lease_expiry_date', label: '租约到期', render: (r) => r.lease_expiry_date ? String(r.lease_expiry_date).substring(0, 10) : '-' },
{ key: 'evaluation_detail', label: '评估详情', render: (r) => <span className="text-xs">{r.evaluation_detail}</span> },
]}
/>
+5 -6
View File
@@ -45,16 +45,15 @@ function buildTree(flatTree: any[]): BomTreeNode[] {
const roots: BomTreeNode[] = []
flatTree.forEach((n: any) => {
const node: BomTreeNode = { ...n, children: [] }
nodeMap.set(node.material_code + '_' + node.level, node)
nodeMap.set(node.path, node)
})
flatTree.forEach((n: any) => {
const key = n.material_code + '_' + n.level
const node = nodeMap.get(key)!
const node = nodeMap.get(n.path)!
if (n.level === 1) {
roots.push(node)
} else {
const parentKey = n.parent_material_code + '_' + (n.level - 1)
const parent = nodeMap.get(parentKey)
const parentPath = n.path.split(' -> ').slice(0, -1).join(' -> ')
const parent = nodeMap.get(parentPath)
if (parent) parent.children.push(node)
else roots.push(node)
}
@@ -176,7 +175,7 @@ export function BomPenetrationPage() {
const childrenToRender = hasChildren ? node.children : (lazyLoaded || [])
const isZero = node.theoretical_qty === 0 && node.issue_qty === 0 && node.theoretical_amt === 0 && node.issue_amt === 0
rows.push(
<tr key={node.material_code + '_' + node.level} className={cn('border-b hover:bg-muted/50', node.is_finished_product && 'bg-purple-50/30', isZero && 'bg-yellow-50/40')}>
<tr key={node.path} className={cn('border-b hover:bg-muted/50', node.is_finished_product && 'bg-purple-50/30', isZero && 'bg-yellow-50/40')}>
<td className="py-1.5 pr-3" style={{ paddingLeft: `${depth * 20 + 8}px` }}>
<div className="flex items-center gap-1.5">
{showChevron ? (
+1 -1
View File
@@ -55,7 +55,7 @@ export function RegionalPage() {
const pageLoading = riskLoading || weeklyLoading || priorityLoading
const weeklyMutation = useMutation({
mutationFn: ({ id, text }: { id: number; text: string }) => api.put(`/tasks/${id}/weekly-check`, { weekly_comment: text }),
mutationFn: ({ id, text }: { id: number; text: string }) => api.put(`/tasks/${id}/weekly-check`, { check_comment: text }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks/weekly-check'] })
setWeeklyModal(null)
+1 -1
View File
@@ -68,7 +68,7 @@ export function RevenuePage() {
received: Number(r.received || 0),
bills: Number(r.bill_count || 0),
avg_bill: Number(r.avg_bill_value || 0),
discount_rate: Number(r.avg_discount_rate_pct || 0),
discount_rate: Number(r.discount_rate_pct || 0),
}))
// 环比
+3 -3
View File
@@ -47,7 +47,7 @@ export function StoreDetailPage() {
const { data: healthData } = useQuery({
queryKey: ['store-health', code, month],
queryFn: () => api.get('/situational-awareness/health-score', { params: { month } }),
queryFn: () => api.get('/situational-awareness/health-score', { params: { month, store_code: code } }),
})
const { data: dailyData } = useQuery({
@@ -125,8 +125,8 @@ export function StoreDetailPage() {
const quadrant = action?.management_quadrant || ''
const quadrantInfo = quadrantDesc[quadrant]
// 公司均值
const companyAvg = { daily_rev: 20144, bill: 36.8, discount: 20.5, margin: 71.5, member: 15.9, repeat: 37.0, delivery: 36.1, combo: 20.6, items: 4.05 }
// 公司均值(从API获取,fallback到默认值)
const companyAvg = sd.companyAvg || { daily_rev: 20144, bill: 36.8, discount: 20.5, margin: 71.5, member: 15.9, repeat: 37.0, delivery: 36.1, combo: 20.6, items: 4.05 }
// 偏差项明细
const deviations: { label: string; store: number; company: number; unit: string; good: 'high' | 'low' }[] = [
+5 -9
View File
@@ -8,6 +8,7 @@ import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar } from 'recharts'
import { Tabs } from '@/components/Tabs'
import { MonthPicker } from '@/components/MonthPicker'
const GRADE_COLORS: Record<string, string> = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' }
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
@@ -20,18 +21,18 @@ export function StoreGradePage() {
const { data: gradeData, isLoading: gradeLoading } = useQuery({
queryKey: ['store-grade/grades', month],
queryFn: () => api.get('/store-grade/grades', { params: { month: month + '-01' } }),
queryFn: () => api.get('/store-grade/grades', { params: { month } }),
enabled: tab === 'grade',
})
const { data: closureData, isLoading: closureLoading } = useQuery({
queryKey: ['store-grade/closure-analysis', month],
queryFn: () => api.get('/store-grade/closure-analysis', { params: { month: month + '-01' } }),
queryFn: () => api.get('/store-grade/closure-analysis', { params: { month } }),
enabled: tab === 'closure',
})
const autoGradeMutation = useMutation({
mutationFn: () => api.post('/store-grade/auto-grade', null, { params: { month: month + '-01' } }),
mutationFn: () => api.post('/store-grade/auto-grade', null, { params: { month } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['store-grade/grades'] })
},
@@ -89,12 +90,7 @@ export function StoreGradePage() {
<p className="mt-0.5 text-xs text-muted-foreground"> · · · {month}</p>
</div>
<div className="flex items-center gap-2">
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded border px-2 py-1 text-sm"
/>
<MonthPicker month={month} onChange={setMonth} />
{tab === 'grade' && (
<button
onClick={() => autoGradeMutation.mutate()}
+62 -32
View File
@@ -129,7 +129,7 @@ router.get('/stores', async (req: AuthRequest, res) => {
router.get('/stores/risk', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 ORDER BY risk_level, received DESC`, [month])
const result = await query(`SELECT store_code, store_name, risk_level, risk_score, received, bill_count, avg_daily_received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bill_share_pct, anomaly_rate_pct, repeat_rate_pct, delivery_bill_share_pct, combo_rate_pct, avg_items_per_bill, primary_issue, month_start FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 ORDER BY risk_level, received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -173,7 +173,7 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const [scorecard, risk, platform, benchmark, action] = await Promise.all([
const [scorecard, risk, platform, benchmark, action, companyAvg] = await Promise.all([
query(`
SELECT c002 AS store_code, c003 AS store_name,
count(*) AS bill_count,
@@ -198,6 +198,20 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
query(`SELECT * FROM analytics.mv_store_action_priority_deep_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
query(`
SELECT
round(avg(avg_daily_received)::numeric, 0) AS daily_rev,
round(avg(avg_bill_value)::numeric, 1) AS bill,
round(avg(discount_rate_pct)::numeric, 1) AS discount,
round(avg(theoretical_margin_pct)::numeric, 1) AS margin,
round(avg(member_bill_share_pct)::numeric, 1) AS member,
round(avg(COALESCE(repeat_rate_pct, 0))::numeric, 1) AS repeat,
round(avg(COALESCE(delivery_bill_share_pct, 0))::numeric, 1) AS delivery,
round(avg(COALESCE(combo_rate_pct, 0))::numeric, 1) AS combo,
round(avg(COALESCE(avg_items_per_bill, 0))::numeric, 2) AS items
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = $1
`, [month]),
])
if (scorecard.rows.length === 0) {
@@ -212,6 +226,7 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
platform: null,
benchmark: null,
action: { management_quadrant: '问题门店', risk_level: null },
companyAvg: companyAvg.rows[0] || null,
})
}
@@ -235,6 +250,7 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
platform: platform.rows[0],
benchmark: benchmark.rows[0],
action: { ...act, management_quadrant: quadrant, risk_level: rk?.risk_level },
companyAvg: companyAvg.rows[0] || null,
})
} catch (err: any) {
sendError(res, err.message)
@@ -337,7 +353,7 @@ router.get('/cost/inventory', async (req: AuthRequest, res) => {
router.get('/platform/economics', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $1 ORDER BY meituan_received DESC NULLS LAST`, [month])
const result = await query(`SELECT store_code, store_name, month_start, platform_share, meituan_received, meituan_cost_rate_pct, eleme_received, eleme_cost_rate_pct, douyin_received, douyin_cost_rate_pct, self_delivery_received, dine_in_received, dine_in_share_pct, delivery_bill_share_pct FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $1 ORDER BY meituan_received DESC NULLS LAST LIMIT 200`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -484,12 +500,16 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => {
const baseWhere = baseConditions.join(' AND ')
const countResult = await query(`SELECT count(*) FILTER (WHERE zero_received_type = '全额优惠') AS full_discount, count(*) FILTER (WHERE zero_received_type = '无消费无优惠') AS empty_bill FROM mv_risk_zero WHERE ${baseWhere}`, baseParams)
const totalResult = await query(`SELECT count(*) AS total FROM mv_risk_zero WHERE ${whereClause}`, params)
const sort = (req.query.sort as string) || 'consumption'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const validSorts = ['consumption', 'discount_total', 'store_name']
const sortCol = validSorts.includes(sort) ? sort : 'consumption'
const result = await query(`
SELECT store_name, bill_no, meal_period, consumption, discount_total,
0 AS received_total, cashier, closed_at, zero_received_type
FROM mv_risk_zero
WHERE ${whereClause}
ORDER BY consumption DESC
ORDER BY ${sortCol} ${order}
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`, [...params, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(totalResult.rows[0].total), full_discount: parseInt(countResult.rows[0].full_discount), empty_bill: parseInt(countResult.rows[0].empty_bill), page, page_size: pageSize })
@@ -501,15 +521,20 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => {
router.get('/risk/cashier', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const sort = (req.query.sort as string) || 'anomaly_bills'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const validSorts = ['anomaly_rate_pct', 'bill_count', 'received', 'anomaly_bills', 'consumption_total']
const sortCol = validSorts.includes(sort) ? sort : 'anomaly_bills'
const result = await query(`
SELECT store_name, cashier, bill_count,
received,
(anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) AS anomaly_bills,
round((anomaly_no_received + anomaly_discount_over + anomaly_unbalanced)::numeric / bill_count * 100, 2) AS anomaly_rate_pct,
consumption_total
consumption_total,
consumption_total AS anomaly_consumption
FROM mv_risk_cashier
WHERE month = to_char($1::date, 'YYYY-MM') AND cashier IS NOT NULL AND cashier != ''
ORDER BY (anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) DESC NULLS LAST
ORDER BY ${sortCol} ${order} NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -586,7 +611,7 @@ router.get('/channel', async (req: AuthRequest, res) => {
const month = parseMonth(req)
const result = await query(`
SELECT business_date, cash, alipay, wechat, meituan, unionpay, douyin, credit, jd_delivery, meituan_delivery, taobao_delivery
FROM mv_channel_daily
FROM analytics.mv_channel_daily
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY business_date
`, [month])
@@ -598,6 +623,7 @@ router.get('/channel', async (req: AuthRequest, res) => {
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const billStats = await query(`
SELECT
count(*) AS total_bills,
@@ -611,15 +637,16 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
min(closed_at)::text AS min_date,
max(closed_at)::text AS max_date
FROM analytics.bill_fact
`)
WHERE closed_at >= $1::date AND closed_at < ($1::date + INTERVAL '1 month')
`, [month])
const dishStats = await query(`
SELECT
count(*) AS total_dish_records,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS dish_missing_store,
count(*) FILTER (WHERE dish_name IS NULL OR dish_name = '') AS dish_missing_dish
FROM public.dish_sales_details
`)
const month = parseMonth(req)
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
`, [month])
const inventoryStats = await query(`
SELECT
count(*) FILTER (WHERE consumption_amount < 0) AS negative_consumption_count,
@@ -972,7 +999,7 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
round(avg(b.received_total), 2) AS avg_bill_value,
round(sum(b.discount_total) / NULLIF(sum(b.consumption), 0) * 100, 2) AS avg_discount_rate,
round(sum(b.theoretical_profit) / NULLIF(sum(b.received_total), 0) * 100, 2) AS avg_margin_rate,
0 AS avg_anomaly_rate,
round(avg(COALESCE(r.anomaly_rate_pct, 0)), 2) AS avg_anomaly_rate,
count(DISTINCT CASE WHEN r.risk_level = '红色' THEN b.store_code END) AS red_count,
count(DISTINCT CASE WHEN r.risk_level = '黄色' THEN b.store_code END) AS yellow_count,
count(DISTINCT CASE WHEN r.risk_level = '绿色' THEN b.store_code END) AS green_count
@@ -992,7 +1019,7 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
router.get('/site-selection/profile', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_site_profile_monthly WHERE month_start = $1 AND business_type='标准门店' AND received>0 ORDER BY received DESC`, [month])
const result = await query(`SELECT store_code, store_name, received, avg_daily_received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bill_share_pct, delivery_bill_share_pct, combo_bill_share_pct, business_type, scale_tier, area_sqm, business_address, city, district, latitude_gcj02, longitude_gcj02, open_date, lease_expiry_date, store_age_years, monthly_received_per_sqm, daily_received_per_sqm, estimated_inventory_days, site_scene, floor_type, area_band, age_band FROM analytics.mv_store_site_profile_monthly WHERE month_start = $1 AND business_type='标准门店' AND received>0 ORDER BY received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1001,7 +1028,7 @@ router.get('/site-selection/profile', async (req: AuthRequest, res) => {
router.get('/site-selection/segment-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_site_segment_benchmark_monthly WHERE month_start = $1 ORDER BY avg_received_per_sqm DESC`, [month])
const result = await query(`SELECT site_scene, area_band, store_count, avg_area_sqm, avg_received, median_received, avg_received_per_sqm, median_received_per_sqm, avg_bill_value, avg_discount_rate_pct, avg_repeat_rate_pct, avg_actual_cost_rate_pct, avg_delivery_share_pct, avg_drink_attach_pct FROM analytics.mv_site_segment_benchmark_monthly WHERE month_start = $1 ORDER BY avg_received_per_sqm DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1010,7 +1037,7 @@ router.get('/site-selection/segment-benchmark', async (req: AuthRequest, res) =>
router.get('/site-selection/replication', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_site_replication_monthly WHERE month_start = $1 ORDER BY site_replication_score DESC`, [month])
const result = await query(`SELECT store_code, store_name, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bill_share_pct, repeat_rate_pct, benchmark_score, area_sqm, business_address, city, district, latitude_gcj02, longitude_gcj02, site_scene, area_band, nearest_store_code, nearest_store_name, nearest_distance_km, site_replication_score, replication_recommendation, spatial_recommendation FROM analytics.mv_store_site_replication_monthly WHERE month_start = $1 ORDER BY site_replication_score DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1019,7 +1046,7 @@ router.get('/site-selection/replication', async (req: AuthRequest, res) => {
router.get('/site-selection/overlap-risk', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_overlap_risk_monthly WHERE month_start = $1 ORDER BY distance_km`, [month])
const result = await query(`SELECT store_code_a, store_name_a, store_code_b, store_name_b, district_a, district_b, scene_a, scene_b, priority_a, priority_b, received_a, received_b, sqm_efficiency_a, sqm_efficiency_b, distance_km, proximity_level, problem_count_a, problem_count_b, overlap_risk FROM analytics.mv_store_overlap_risk_monthly WHERE month_start = $1 ORDER BY distance_km LIMIT 200`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1028,7 +1055,7 @@ router.get('/site-selection/overlap-risk', async (req: AuthRequest, res) => {
router.get('/site-selection/district-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_district_site_benchmark_monthly WHERE month_start = $1 ORDER BY total_received DESC`, [month])
const result = await query(`SELECT city, district, store_count, avg_area_sqm, total_received, avg_received, median_received, avg_received_per_sqm, avg_bill_value, avg_discount_rate_pct, avg_repeat_rate_pct, avg_actual_cost_rate_pct, avg_platform_cost_rate_pct, p0_count, p1_count FROM analytics.mv_district_site_benchmark_monthly WHERE month_start = $1 ORDER BY total_received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1543,6 +1570,7 @@ router.get('/revenue/store-ranking', async (req: AuthRequest, res) => {
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY store_code, store_name
ORDER BY sum(received) DESC
LIMIT 200
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -1710,7 +1738,7 @@ router.get('/central-kitchen/dashboard', async (req: AuthRequest, res) => {
round(sum(c.inbound_quantity * c.inbound_avg_unit_price)::numeric,2) AS calculated_inbound_value,
round((sum(c.inbound_quantity * c.inbound_avg_unit_price) - sum(p.full_manufacturing_cost))::numeric,2) AS manufacturing_margin
FROM analytics.v_central_kitchen_product_full_cost p
JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
LEFT JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
WHERE p.report_month = $1::date
`, [month]),
// 产品明细列表
@@ -1734,7 +1762,7 @@ router.get('/central-kitchen/dashboard', async (req: AuthRequest, res) => {
round((c.inbound_quantity * c.inbound_avg_unit_price)::numeric,2) AS inbound_value,
round((c.inbound_quantity * c.inbound_avg_unit_price - p.full_manufacturing_cost)::numeric,2) AS product_margin
FROM analytics.v_central_kitchen_product_full_cost p
JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
LEFT JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
WHERE p.report_month = $1::date
ORDER BY p.full_manufacturing_cost DESC
`, [month]),
@@ -1925,7 +1953,7 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
// 检查配送物化视图是否包含当前月份数据
const distMvCheck = await query(`SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1`, [month])
if (distMvCheck.rows.length === 0) {
await query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_distribution_monthly`)
console.warn(`[distribution/reconciliation] mv_distribution_monthly 缺少 ${month} 数据,请通过ETL流程刷新物化视图`)
}
const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation] = await Promise.all([
@@ -2089,9 +2117,10 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
GROUP BY COALESCE(NULLIF(d.minor_category,''),'未分类')
),
item_cat AS (
SELECT DISTINCT item_code, COALESCE(NULLIF(minor_category,''),'未分类') AS minor_category
FROM mv_distribution_monthly
WHERE report_month = $1::date
SELECT DISTINCT ic.item_code, COALESCE(NULLIF(dm.minor_category,''), NULLIF(ic.minor_category,''),'未分类') AS minor_category
FROM mv_distribution_monthly ic
LEFT JOIN analytics.dim_material dm ON ic.item_code = dm.material_code
WHERE ic.report_month = $1::date
),
inv AS (
SELECT ic.minor_category,
@@ -2396,13 +2425,12 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
const client = await pool.connect()
try {
// 检查物化视图是否包含当前月份数据,如缺失则刷新
// 检查物化视图是否包含当前月份数据
const mvCheck = await client.query(`
SELECT 1 FROM mv_dish_sales_monthly WHERE month = $1::date LIMIT 1
`, [month])
if (mvCheck.rows.length === 0) {
// 物化视图中没有该月数据,刷新物化视图
await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dish_sales_monthly`)
console.warn(`[production-plan] mv_dish_sales_monthly 缺少 ${month} 数据,请通过ETL流程刷新物化视图`)
}
// 检查配送物化视图是否包含当前月份数据
@@ -2410,7 +2438,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1
`, [month])
if (distMvCheck.rows.length === 0) {
await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_distribution_monthly`)
console.warn(`[production-plan] mv_distribution_monthly 缺少 ${month} 数据,请通过ETL流程刷新物化视图`)
}
// 从物化视图创建临时表(索引扫描,毫秒级)
@@ -2519,7 +2547,8 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
count(DISTINCT s.sku_code) AS sku_count,
round(sum(s.qty)::numeric,2) AS total_qty,
round(sum(s.amt)::numeric,2) AS total_amt,
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS total_material_demand_qty
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS total_material_demand_qty,
count(DISTINCT b.unit) AS material_unit_count
FROM tmp_sales_agg s
LEFT JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
GROUP BY s.store_code, s.store_name
@@ -2531,7 +2560,8 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
round(sd.total_material_demand_qty::numeric,4) AS total_material_demand_qty,
COALESCE(inv.ending_qty, 0) AS ending_inventory_qty,
COALESCE(inv.ending_amt, 0) AS ending_inventory_amt,
round((sd.total_material_demand_qty - COALESCE(inv.ending_qty, 0))::numeric,4) AS suggested_order_qty
round((sd.total_material_demand_qty - COALESCE(inv.ending_qty, 0))::numeric,4) AS suggested_order_qty,
sd.material_unit_count
FROM store_demand sd
LEFT JOIN (
SELECT store_code,
@@ -2555,8 +2585,8 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
FROM tmp_sales_agg s
JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
JOIN analytics.dim_material m ON b.material_code = m.material_code
WHERE m.material_name IN (
SELECT product_name FROM central_kitchen_processing_cost WHERE report_month = $1::date
WHERE b.material_code IN (
SELECT product_code FROM central_kitchen_processing_cost WHERE report_month = $1::date
)
GROUP BY b.material_code, m.material_name, b.unit
),
@@ -2577,14 +2607,14 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
COALESCE(a.actual_inbound_qty, 0) AS actual_inbound_qty,
COALESCE(a.actual_cost, 0) AS actual_cost
FROM ck_demand d
LEFT JOIN ck_actual a ON d.ck_product_name = a.product_name
LEFT JOIN ck_actual a ON d.ck_product_code = a.product_code
UNION ALL
SELECT a.product_code, a.product_name, '',
0, 0, a.actual_inbound_qty, a.actual_cost
FROM ck_actual a
WHERE a.product_name NOT IN (SELECT ck_product_name FROM ck_demand)
WHERE a.product_code NOT IN (SELECT ck_product_code FROM ck_demand)
)
SELECT product_code, product_name, unit,
demand_qty, store_count, actual_inbound_qty, actual_cost,
+15 -3
View File
@@ -216,13 +216,25 @@ router.get('/hr/talent-matrix', async (req: AuthRequest, res) => {
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
COALESCE(ps.performance_score, 70) AS performance_score,
COALESCE(ls.potential_score, 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
) ps ON true
LEFT JOIN LATERAL (
SELECT CASE
WHEN COUNT(*) > 0 THEN
LEAST(100, ROUND(
(COUNT(*) FILTER (WHERE completion_status = '已完成')::numeric / COUNT(*) * 50 +
COALESCE(AVG(exam_score), 50) / 100 * 50
)::numeric, 1
))
ELSE NULL
END AS potential_score
FROM analytics.v3_learning_record lr WHERE lr.employee_name = e.employee_name
) ls ON true
WHERE e.status = '在职'
) t
ORDER BY performance_score DESC, potential_score DESC
+14 -9
View File
@@ -12,12 +12,14 @@ router.get('/health-score', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const scope = getDataScope(req)
const storeCode = (req.query.store_code as string) || ''
const scopeCodes = scope?.role === 'store' || scope?.role === 'regional'
? (scope.storeCode || scope.region || '').split(',').map(s => s.trim()).filter(Boolean)
: []
const hasScope = scopeCodes.length > 0
const scopeParams = hasScope ? [month, scopeCodes] : [month]
const scopeFilter = hasScope ? ` AND store_code = ANY($2)` : ''
const hasStoreCode = !!storeCode
const scopeParams = hasStoreCode ? [month, storeCode] : hasScope ? [month, scopeCodes] : [month]
const scopeFilter = hasStoreCode ? ` AND store_code = $2` : hasScope ? ` AND store_code = ANY($2)` : ''
const result = await query(`
WITH base AS (
SELECT store_code, store_name, received, bill_count,
@@ -122,7 +124,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
WITH daily_stats AS (
SELECT business_date, bill_count, received
FROM analytics.mv_daily_revenue
WHERE business_date >= (SELECT max(business_date) - interval '30 days' FROM analytics.mv_daily_revenue)
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
ORDER BY business_date
),
monthly_avg AS (
@@ -135,7 +137,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
WHERE d.received < m.avg_received * 0.7
ORDER BY d.business_date DESC
LIMIT 10
`, [], { skipScope: true }),
`, [month], { skipScope: true }),
// 2. 成本异动预警:成本差异>30%的菜品
query(`
SELECT dish_name, dish_code, category_level1,
@@ -143,12 +145,13 @@ router.get('/alerts', async (req: AuthRequest, res) => {
round((cost_variance_amount / nullif(theoretical_cost, 0) * 100)::numeric, 2) AS variance_pct,
round(sales_amount::numeric, 2) AS sales_amount
FROM public.dish_cost_analysis_summary
WHERE cost_variance_amount > 0
WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
AND cost_variance_amount > 0
AND theoretical_cost > 0
AND (cost_variance_amount / theoretical_cost) > 0.3
ORDER BY variance_pct DESC
LIMIT 10
`, [], { skipScope: true }),
`, [month], { skipScope: true }),
// 3. 人力异动预警:考勤异常员工
query(`
SELECT s.org_level5 AS store_name, count(*) AS alert_count,
@@ -368,6 +371,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
round(count(*) FILTER (WHERE s.actual_attend / nullif(s.expected_attend, 0) < 0.8)::numeric / nullif(count(*), 0) * 100, 1) AS low_attend_rate
FROM salary_detail_records s
WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
AND s.salary_month = to_char($1::date, 'YYYY-MM')
GROUP BY s.org_level5
),
rev AS (
@@ -399,7 +403,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
ORDER BY
CASE WHEN COALESCE(h.low_attend_rate, 0) > 30 THEN 0 ELSE 1 END,
COALESCE(h.total_absent, 0) DESC
`)
`, [month])
sendSuccess(res, {
staffing_efficiency: staffingRows,
@@ -462,10 +466,11 @@ router.get('/forecast', async (req: AuthRequest, res) => {
ELSE '正常'
END AS trend_status
FROM public.dish_cost_analysis_summary
WHERE actual_margin_rate_pct IS NOT NULL
WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
AND actual_margin_rate_pct IS NOT NULL
ORDER BY variance DESC
LIMIT 20
`)
`, [month])
// 人员流失预警
const turnoverAlert = await query(`
+13 -2
View File
@@ -376,6 +376,7 @@ router.get('/efficiency', async (req: AuthRequest, res) => {
round(bill_count::numeric, 0) AS bill_count,
round(received / nullif(bill_count, 0)::numeric, 2) AS avg_ticket_size,
CASE
WHEN received_per_sqm < 500 THEN '坪效极低'
WHEN received_per_sqm < 1000 THEN '坪效偏低'
WHEN received_per_sqm < 2000 THEN '坪效一般'
WHEN received_per_sqm < 4000 THEN '坪效良好'
@@ -438,7 +439,7 @@ router.get('/fixed-variable', async (req: AuthRequest, res) => {
round((rent_expense + dorm_expense + repair_clean_expense * 0.5) / nullif(received, 0) * 100::numeric, 2) AS fixed_rate_pct,
round((wage_expense + utility_expense + delivery_commission_expense + card_fee_expense + repair_clean_expense * 0.5) / nullif(received, 0) * 100::numeric, 2) AS variable_rate_pct,
round((received - actual_food_cost - wage_expense - utility_expense - delivery_commission_expense - card_fee_expense - repair_clean_expense * 0.5)::numeric, 2) AS contribution_after_variable,
round((rent_expense + dorm_expense + repair_clean_expense * 0.5)::numeric, 2) AS break_even_sales
round((rent_expense + dorm_expense + repair_clean_expense * 0.5) / nullif(1 - actual_food_cost / nullif(received, 0), 0)::numeric, 2) AS break_even_sales
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
@@ -539,6 +540,16 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
const total = countResult.rows[0].count
// 全量统计(不受分页影响)
const evalStats = await query(`
SELECT
count(*) FILTER (WHERE actual_store_contribution_rate_pct < -20) AS p0_count,
count(*) FILTER (WHERE actual_store_contribution_rate_pct >= -20 AND actual_store_contribution_rate_pct < -5) AS p1_count,
round(sum(actual_store_contribution)::numeric, 2) AS total_loss
FROM analytics.mv_store_operating_expense_monthly
WHERE report_month = $1::date AND actual_store_contribution <= 0 AND received > 0
`, [month])
const validSorts = ['actual_store_contribution', 'actual_store_contribution_rate_pct', 'received', 'wage_rate_pct', 'rent_rate_pct', 'utility_rate_pct', 'received_per_sqm', 'operating_expense_rate_pct']
const sortCol = validSorts.includes(sort) ? sort : 'actual_store_contribution'
@@ -737,7 +748,7 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
}
})
sendSuccess(res, rows, { page, pageSize, total })
sendSuccess(res, rows, { page, pageSize, total, evalStats: evalStats.rows[0] })
} catch (err: any) {
sendError(res, err.message)
}
+6 -6
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { query, withTransaction } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import { sendSuccess, sendError, parseMonth } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
@@ -8,7 +8,7 @@ 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 gradeMonth = parseMonth(req)
const storesResult = await query(`
SELECT
@@ -20,7 +20,7 @@ router.post('/auto-grade', async (req: AuthRequest, res) => {
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
ON s.store_code = ds.store_code AND s.month_start = date_trunc('month', $1::date)
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
@@ -73,7 +73,7 @@ router.post('/auto-grade', async (req: AuthRequest, res) => {
// 获取门店分级列表(含风险等级详情)
router.get('/grades', async (req: AuthRequest, res) => {
try {
const gradeMonth = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const gradeMonth = parseMonth(req)
const result = await query(`
SELECT
g.*,
@@ -103,7 +103,7 @@ router.get('/grades', async (req: AuthRequest, res) => {
// 红黄绿灯状态 — 基于实时经营健康度(达成率+异常率+风险等级),与分级维度不同
router.get('/traffic-light', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const month = parseMonth(req)
const result = await query(`
SELECT * FROM (
SELECT
@@ -147,7 +147,7 @@ router.get('/traffic-light', async (req: AuthRequest, res) => {
// ============================================================
router.get('/closure-analysis', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const month = parseMonth(req)
const result = await query(`
WITH store_financials AS (
+3266 -70
View File
File diff suppressed because it is too large Load Diff