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:
@@ -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> },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
|
||||
// 环比
|
||||
|
||||
@@ -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' }[] = [
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user