feat: 门店费用分析模块 + 成本分析筛选排序功能
- 新增门店费用分析模块(9个Tab组件 + 后端路由) - 门店贡献利润、房租租约风险、外卖佣金分析、人效坪效 - 固定/变动费用、盈亏平衡分析、亏损门店诊断 - 关停/续租/改造评估、门店排名 - 门店费用分析9个Tab均支持筛选、排序、正倒序 - 成本分析8个Tab添加筛选、排序、正倒序功能 - 后端10个端点添加filter/sort/order参数支持 - 前端8个组件添加UI控件 - 修复门店评估分类逻辑和日期格式化 - 修复盈亏平衡销售额计算(贡献毛利率≤0时设为NULL)
This commit is contained in:
@@ -13,6 +13,7 @@ import { StorePage } from '@/pages/StorePage'
|
||||
import { SKUPage } from '@/pages/SKUPage'
|
||||
import { CostPage } from '@/pages/CostPage'
|
||||
import { CostAnalysisPage } from '@/pages/CostAnalysisPage'
|
||||
import { StoreExpensePage } from '@/pages/StoreExpensePage'
|
||||
import { PlatformPage } from '@/pages/PlatformPage'
|
||||
import { MemberPage } from '@/pages/MemberPage'
|
||||
import { RiskPage } from '@/pages/RiskPage'
|
||||
@@ -73,6 +74,7 @@ export default function App() {
|
||||
<Route path="/sku" element={<SKUPage />} />
|
||||
<Route path="/cost" element={<CostPage />} />
|
||||
<Route path="/cost-analysis" element={<CostAnalysisPage />} />
|
||||
<Route path="/store-expense" element={<StoreExpensePage />} />
|
||||
<Route path="/platform" element={<PlatformPage />} />
|
||||
<Route path="/member" element={<MemberPage />} />
|
||||
<Route path="/risk" element={<RiskPage />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart } from 'lucide-react'
|
||||
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -38,6 +38,7 @@ const menuGroups: MenuGroup[] = [
|
||||
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
|
||||
{ path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] },
|
||||
{ path: '/cost-analysis', label: '菜品成本分析', icon: PieChart, roles: ['hq', 'dept'] },
|
||||
{ path: '/store-expense', label: '门店费用分析', icon: Wallet, roles: ['hq', 'dept'] },
|
||||
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
|
||||
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
|
||||
{ path: '/risk', label: '风险内控', icon: AlertTriangle, roles: ['hq', 'dept'] },
|
||||
|
||||
@@ -37,12 +37,16 @@ export function AdjustmentTab() {
|
||||
const [adjPage, setAdjPage] = useState(1)
|
||||
const [diagPriority, setDiagPriority] = useState('')
|
||||
const [adjStatus, setAdjStatus] = useState('')
|
||||
const [diagSort, setDiagSort] = useState('priority')
|
||||
const [diagOrder, setDiagOrder] = useState('asc')
|
||||
const [adjSort, setAdjSort] = useState('effective_date')
|
||||
const [adjOrder, setAdjOrder] = useState('desc')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [verifyId, setVerifyId] = useState<number | null>(null)
|
||||
const [formData, setFormData] = useState<any>({})
|
||||
|
||||
const { data: diag, isLoading: ld } = useQuery({ queryKey: ['ca/diagnosis', diagPage, diagPriority], queryFn: () => api.get(`/cost-analysis/diagnosis?page=${diagPage}&page_size=${PAGE_SIZE}${diagPriority ? `&priority=${diagPriority}` : ''}`) })
|
||||
const { data: adj, isLoading: la } = useQuery({ queryKey: ['ca/adjustment', adjPage, adjStatus], queryFn: () => api.get(`/cost-analysis/adjustment?page=${adjPage}&page_size=${PAGE_SIZE}${adjStatus ? `&status=${adjStatus}` : ''}`) })
|
||||
const { data: diag, isLoading: ld } = useQuery({ queryKey: ['ca/diagnosis', diagPage, diagPriority, diagSort, diagOrder], queryFn: () => api.get(`/cost-analysis/diagnosis?page=${diagPage}&page_size=${PAGE_SIZE}&sort=${diagSort}&order=${diagOrder}${diagPriority ? `&priority=${diagPriority}` : ''}`) })
|
||||
const { data: adj, isLoading: la } = useQuery({ queryKey: ['ca/adjustment', adjPage, adjStatus, adjSort, adjOrder], queryFn: () => api.get(`/cost-analysis/adjustment?page=${adjPage}&page_size=${PAGE_SIZE}&sort=${adjSort}&order=${adjOrder}${adjStatus ? `&status=${adjStatus}` : ''}`) })
|
||||
const { data: verify, isLoading: lv } = useQuery({ queryKey: ['ca/adjustment/verify', verifyId], queryFn: () => api.get(`/cost-analysis/adjustment/${verifyId}/verify`), enabled: verifyId !== null })
|
||||
|
||||
const generateDiagnosis = useMutation({
|
||||
@@ -120,6 +124,19 @@ export function AdjustmentTab() {
|
||||
<option value="P2">P2</option>
|
||||
<option value="P3">P3</option>
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'priority', label: '优先级' },
|
||||
{ key: 'cost_variance_amount', label: '成本差异' },
|
||||
{ key: 'sales_amount', label: '销售额' },
|
||||
{ key: 'actual_margin_pct', label: '实际毛利率' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setDiagSort(s.key); setDiagPage(1) }} className={`px-3 py-1 rounded text-xs ${diagSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setDiagOrder(diagOrder === 'asc' ? 'desc' : 'asc'); setDiagPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{diagOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
@@ -189,7 +206,7 @@ export function AdjustmentTab() {
|
||||
|
||||
{subTab === 'adjustment' && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select value={adjStatus} onChange={(e) => { setAdjStatus(e.target.value); setAdjPage(1) }} className="rounded border px-2 py-1.5 text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="planned">待执行</option>
|
||||
@@ -197,6 +214,18 @@ export function AdjustmentTab() {
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'effective_date', label: '执行日期' },
|
||||
{ key: 'dish_name', label: '菜品名' },
|
||||
{ key: 'adjustment_type', label: '调整类型' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setAdjSort(s.key); setAdjPage(1) }} className={`px-3 py-1 rounded text-xs ${adjSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setAdjOrder(adjOrder === 'asc' ? 'desc' : 'asc'); setAdjPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{adjOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
{la ? <LoadingSpinner text="加载调整记录..." /> : (
|
||||
<>
|
||||
|
||||
@@ -15,11 +15,16 @@ export function BomTab() {
|
||||
const [complexPage, setComplexPage] = useState(1)
|
||||
const [missingPage, setMissingPage] = useState(1)
|
||||
const [selectedSku, setSelectedSku] = useState('')
|
||||
const [complexSort, setComplexSort] = useState('material_count')
|
||||
const [complexOrder, setComplexOrder] = useState('desc')
|
||||
const [complexFilter, setComplexFilter] = useState('')
|
||||
const [missSort, setMissSort] = useState('sales_amount')
|
||||
const [missOrder, setMissOrder] = useState('desc')
|
||||
|
||||
const { data: bomOv, isLoading: l1 } = useQuery({ queryKey: ['ca/bom-overview'], queryFn: () => api.get('/cost-analysis/bom-overview') })
|
||||
const { data: complexity, isLoading: l2 } = useQuery({ queryKey: ['ca/bom-complexity', complexPage], queryFn: () => api.get(`/cost-analysis/bom-complexity?page=${complexPage}&page_size=${PAGE_SIZE}`) })
|
||||
const { data: complexity, isLoading: l2 } = useQuery({ queryKey: ['ca/bom-complexity', complexPage, complexSort, complexOrder, complexFilter], queryFn: () => api.get(`/cost-analysis/bom-complexity?page=${complexPage}&page_size=${PAGE_SIZE}&sort=${complexSort}&order=${complexOrder}&filter=${complexFilter}`) })
|
||||
const { data: highLoss, isLoading: l3 } = useQuery({ queryKey: ['ca/bom-high-loss'], queryFn: () => api.get('/cost-analysis/bom-high-loss?threshold=20') })
|
||||
const { data: missing, isLoading: l4 } = useQuery({ queryKey: ['ca/bom-missing', missingPage], queryFn: () => api.get(`/cost-analysis/bom-missing?page=${missingPage}&page_size=${PAGE_SIZE}`) })
|
||||
const { data: missing, isLoading: l4 } = useQuery({ queryKey: ['ca/bom-missing', missingPage, missSort, missOrder], queryFn: () => api.get(`/cost-analysis/bom-missing?page=${missingPage}&page_size=${PAGE_SIZE}&sort=${missSort}&order=${missOrder}`) })
|
||||
const { data: composition, isLoading: l5 } = useQuery({
|
||||
queryKey: ['ca/bom-composition', selectedSku],
|
||||
queryFn: () => api.get(`/cost-analysis/bom-composition?sku_code=${selectedSku}`),
|
||||
@@ -79,7 +84,28 @@ export function BomTab() {
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="BOM复杂度明细" subtitle="按物料数降序">
|
||||
<CollapsibleSection title="BOM复杂度明细" subtitle="支持筛选和排序">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'high_loss', label: '含高损耗' },
|
||||
{ key: 'unique_heavy', label: '独有原料多' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setComplexFilter(f.key); setComplexPage(1) }} className={`px-3 py-1 rounded text-xs ${complexFilter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'material_count', label: '原料数' },
|
||||
{ key: 'unique_material_count', label: '独有原料' },
|
||||
{ key: 'high_loss_count', label: '高损耗' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setComplexSort(s.key); setComplexPage(1) }} className={`px-3 py-1 rounded text-xs ${complexSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setComplexOrder(complexOrder === 'asc' ? 'desc' : 'asc'); setComplexPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{complexOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={complexPage} pageSize={PAGE_SIZE} total={compMeta.total} onPageChange={setComplexPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
@@ -138,6 +164,19 @@ export function BomTab() {
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="缺BOM的SKU清单" subtitle="优先补齐高销量SKU的BOM" defaultOpen={false}>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: 'sales_amount', label: '销售额' },
|
||||
{ key: 'sales_quantity', label: '销量' },
|
||||
{ key: 'standard_name', label: '菜品名' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setMissSort(s.key); setMissingPage(1) }} className={`px-3 py-1 rounded text-xs ${missSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setMissOrder(missOrder === 'asc' ? 'desc' : 'asc'); setMissingPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{missOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={missingPage} pageSize={PAGE_SIZE} total={missMeta.total} onPageChange={setMissingPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
|
||||
@@ -15,6 +15,8 @@ export function MaterialTab() {
|
||||
const [dishCode, setDishCode] = useState('')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [topSort, setTopSort] = useState('total_loss_qty')
|
||||
const [topOrder, setTopOrder] = useState('asc')
|
||||
|
||||
const { data: variance, isLoading: lv } = useQuery({
|
||||
queryKey: ['ca/material-variance', dishCode],
|
||||
@@ -30,6 +32,11 @@ export function MaterialTab() {
|
||||
const varianceData = (variance as any)?.data || []
|
||||
const distData = (lossDist as any)?.data || []
|
||||
const topData = (lossTop as any)?.data || []
|
||||
const sortedTopData = [...topData].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[topSort]) || 0
|
||||
const bv = parseFloat(b[topSort]) || 0
|
||||
return topOrder === 'asc' ? av - bv : bv - av
|
||||
})
|
||||
const typeData = (typeLoss as any)?.data || []
|
||||
|
||||
const reasonColor = (reason: string) => {
|
||||
@@ -129,8 +136,22 @@ export function MaterialTab() {
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="超耗物料TOP榜" subtitle="按损耗量排序">
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={topData.length} onPageChange={setPage} />
|
||||
<CollapsibleSection title="超耗物料TOP榜" subtitle="支持排序">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: 'total_loss_qty', label: '总损耗量' },
|
||||
{ key: 'avg_loss_rate', label: '平均损耗率' },
|
||||
{ key: 'total_loss_amount', label: '损耗金额' },
|
||||
{ key: 'dish_count', label: '涉及菜品数' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setTopSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${topSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setTopOrder(topOrder === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{topOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={sortedTopData.length} onPageChange={setPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
@@ -142,7 +163,7 @@ export function MaterialTab() {
|
||||
{ key: 'max_loss_rate', label: '最大损耗率', align: 'right', render: (r) => formatPercent(r.max_loss_rate) },
|
||||
{ key: 'total_loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.total_loss_amount) },
|
||||
]}
|
||||
data={topData.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)}
|
||||
data={sortedTopData.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -12,9 +12,12 @@ const PAGE_SIZE = 15
|
||||
|
||||
export function PackagingTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('total_cost')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/packaging-overview'], queryFn: () => api.get('/cost-analysis/packaging-overview') })
|
||||
const { data: detail, isLoading: l2 } = useQuery({ queryKey: ['ca/packaging-detail', page], queryFn: () => api.get(`/cost-analysis/packaging-detail?page=${page}&page_size=${PAGE_SIZE}`) })
|
||||
const { data: detail, isLoading: l2 } = useQuery({ queryKey: ['ca/packaging-detail', page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/packaging-detail?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}&filter=${filter}`) })
|
||||
|
||||
if (l1 || l2) return <LoadingSpinner text="加载包装耗材数据..." />
|
||||
|
||||
@@ -38,6 +41,28 @@ export function PackagingTab() {
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="包装物料明细" subtitle="餐盒、餐具、打包袋等耗材使用情况">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'high_loss', label: '高损耗' },
|
||||
{ key: 'normal', label: '正常' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'total_cost', label: '总成本' },
|
||||
{ key: 'loss_qty', label: '损耗量' },
|
||||
{ key: 'avg_loss_rate', label: '损耗率' },
|
||||
{ key: 'dish_count', label: '涉及菜品数' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={meta.total} onPageChange={setPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
|
||||
@@ -20,9 +20,12 @@ const MENU_COLORS: Record<string, string> = {
|
||||
export function ProfitabilityTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [category, setCategory] = useState('')
|
||||
const [sort, setSort] = useState('sales_amount')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const { data: matrix, isLoading: l1 } = useQuery({ queryKey: ['ca/menu-engineering'], queryFn: () => api.get('/cost-analysis/menu-engineering') })
|
||||
const { data: profit, isLoading: l2 } = useQuery({ queryKey: ['ca/profitability', page, category], queryFn: () => api.get(`/cost-analysis/profitability?page=${page}&page_size=${PAGE_SIZE}${category ? `&category=${category}` : ''}`) })
|
||||
const { data: profit, isLoading: l2 } = useQuery({ queryKey: ['ca/profitability', page, category, sort, order, filter], queryFn: () => api.get(`/cost-analysis/profitability?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}&filter=${filter}${category ? `&category=${category}` : ''}`) })
|
||||
const { data: pricing, isLoading: l3 } = useQuery({ queryKey: ['ca/pricing'], queryFn: () => api.get('/cost-analysis/pricing?threshold=50') })
|
||||
|
||||
if (l1 || l2 || l3) return <LoadingSpinner text="加载菜品盈利数据..." />
|
||||
@@ -81,9 +84,32 @@ export function ProfitabilityTab() {
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="菜品盈利明细" subtitle="按销售额降序">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">品类筛选:</label>
|
||||
<CollapsibleSection title="菜品盈利明细" subtitle="支持筛选和排序">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利菜品' },
|
||||
{ key: 'loss', label: '亏损菜品' },
|
||||
{ key: 'high_variance', label: '成本超耗' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'sales_amount', label: '销售额' },
|
||||
{ key: 'sales_quantity', label: '销量' },
|
||||
{ key: 'actual_margin_rate_pct', label: '实际毛利率' },
|
||||
{ key: 'theoretical_margin_rate_pct', label: '理论毛利率' },
|
||||
{ key: 'cost_variance_amount', label: '成本差异' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<label className="text-xs text-muted-foreground">品类:</label>
|
||||
<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-xs">
|
||||
<option value="">全部</option>
|
||||
{categories.map((c: any) => <option key={c} value={c}>{c}</option>)}
|
||||
|
||||
@@ -13,10 +13,14 @@ const PAGE_SIZE = 15
|
||||
export function QualityTab() {
|
||||
const [dishPage, setDishPage] = useState(1)
|
||||
const [matPage, setMatPage] = useState(1)
|
||||
const [dishSort, setDishSort] = useState('sales_amount')
|
||||
const [dishOrder, setDishOrder] = useState('desc')
|
||||
const [matSort, setMatSort] = useState('dish_count')
|
||||
const [matOrder, setMatOrder] = useState('desc')
|
||||
|
||||
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/data-quality'], queryFn: () => api.get('/cost-analysis/data-quality') })
|
||||
const { data: dishes, isLoading: l2 } = useQuery({ queryKey: ['ca/unmatched-dishes', dishPage], queryFn: () => api.get(`/cost-analysis/unmatched-dishes?page=${dishPage}&page_size=${PAGE_SIZE}`) })
|
||||
const { data: materials, isLoading: l3 } = useQuery({ queryKey: ['ca/unmatched-materials', matPage], queryFn: () => api.get(`/cost-analysis/unmatched-materials?page=${matPage}&page_size=${PAGE_SIZE}`) })
|
||||
const { data: dishes, isLoading: l2 } = useQuery({ queryKey: ['ca/unmatched-dishes', dishPage, dishSort, dishOrder], queryFn: () => api.get(`/cost-analysis/unmatched-dishes?page=${dishPage}&page_size=${PAGE_SIZE}&sort=${dishSort}&order=${dishOrder}`) })
|
||||
const { data: materials, isLoading: l3 } = useQuery({ queryKey: ['ca/unmatched-materials', matPage, matSort, matOrder], queryFn: () => api.get(`/cost-analysis/unmatched-materials?page=${matPage}&page_size=${PAGE_SIZE}&sort=${matSort}&order=${matOrder}`) })
|
||||
|
||||
if (l1 || l2 || l3) return <LoadingSpinner text="加载数据质量..." />
|
||||
|
||||
@@ -63,6 +67,18 @@ export function QualityTab() {
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="未匹配菜品清单" subtitle="菜品成本报表中未匹配到销售数据的菜品" defaultOpen={false}>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: 'sales_amount', label: '销售额' },
|
||||
{ key: 'dish_name', label: '菜品名' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setDishSort(s.key); setDishPage(1) }} className={`px-3 py-1 rounded text-xs ${dishSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setDishOrder(dishOrder === 'asc' ? 'desc' : 'asc'); setDishPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{dishOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={dishPage} pageSize={PAGE_SIZE} total={dishMeta.total} onPageChange={setDishPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
@@ -78,6 +94,19 @@ export function QualityTab() {
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="未匹配物料清单" subtitle="未匹配到库存数据的物料" defaultOpen={false}>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: 'dish_count', label: '涉及菜品数' },
|
||||
{ key: 'loss_amount', label: '损耗金额' },
|
||||
{ key: 'material_name', label: '物料名' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setMatSort(s.key); setMatPage(1) }} className={`px-3 py-1 rounded text-xs ${matSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setMatOrder(matOrder === 'asc' ? 'desc' : 'asc'); setMatPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{matOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={matPage} pageSize={PAGE_SIZE} total={matMeta.total} onPageChange={setMatPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
|
||||
@@ -19,9 +19,12 @@ const LEVEL_COLORS: Record<string, string> = {
|
||||
|
||||
export function StoreTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('food_cost_variance')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/store-overview'], queryFn: () => api.get('/cost-analysis/store-overview') })
|
||||
const { data: ranking, isLoading: l2 } = useQuery({ queryKey: ['ca/store-ranking', page], queryFn: () => api.get(`/cost-analysis/store-ranking?page=${page}&page_size=${PAGE_SIZE}`) })
|
||||
const { data: ranking, isLoading: l2 } = useQuery({ queryKey: ['ca/store-ranking', page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/store-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}&filter=${filter}`) })
|
||||
|
||||
if (l1 || l2) return <LoadingSpinner text="加载门店成本数据..." />
|
||||
|
||||
@@ -65,7 +68,31 @@ export function StoreTab() {
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="门店超耗排名" subtitle="按成本差异金额降序">
|
||||
<CollapsibleSection title="门店超耗排名" subtitle="支持筛选和排序">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'red', label: '红色-严重超耗' },
|
||||
{ key: 'orange', label: '橙色-明显超耗' },
|
||||
{ key: 'green', label: '绿色-基本正常' },
|
||||
{ key: 'gray', label: '灰色-口径异常' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'food_cost_variance', label: '超耗金额' },
|
||||
{ key: 'variance_to_theoretical_pct', label: '偏差率' },
|
||||
{ key: 'actual_food_cost_rate_pct', label: '实际成本率' },
|
||||
{ key: 'negative_item_lines', label: '负库存项' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={meta.total} onPageChange={setPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
|
||||
@@ -17,9 +17,14 @@ export function SupplyTab() {
|
||||
const [skuInput, setSkuInput] = useState('')
|
||||
const [simResult, setSimResult] = useState<any>(null)
|
||||
const [simLoading, setSimLoading] = useState(false)
|
||||
const [riskSort, setRiskSort] = useState('sales_amount')
|
||||
const [riskOrder, setRiskOrder] = useState('asc')
|
||||
const [riskFilter, setRiskFilter] = useState('')
|
||||
const [demandSort, setDemandSort] = useState('estimated_demand')
|
||||
const [demandOrder, setDemandOrder] = useState('desc')
|
||||
|
||||
const { data: sharing, isLoading: l1 } = useQuery({ queryKey: ['ca/material-sharing'], queryFn: () => api.get('/cost-analysis/material-sharing?limit=30') })
|
||||
const { data: risk, isLoading: l2 } = useQuery({ queryKey: ['ca/unique-material-risk', riskPage], queryFn: () => api.get(`/cost-analysis/unique-material-risk?page=${riskPage}&page_size=${PAGE_SIZE}`) })
|
||||
const { data: risk, isLoading: l2 } = useQuery({ queryKey: ['ca/unique-material-risk', riskPage, riskSort, riskOrder, riskFilter], queryFn: () => api.get(`/cost-analysis/unique-material-risk?page=${riskPage}&page_size=${PAGE_SIZE}&sort=${riskSort}&order=${riskOrder}&filter=${riskFilter}`) })
|
||||
const { data: demand, isLoading: l3 } = useQuery({ queryKey: ['ca/material-demand'], queryFn: () => api.get('/cost-analysis/material-demand?limit=100') })
|
||||
|
||||
if (l1 || l2 || l3) return <LoadingSpinner text="加载供应链数据..." />
|
||||
@@ -27,7 +32,13 @@ export function SupplyTab() {
|
||||
const sharingData = (sharing as any)?.data || []
|
||||
const riskData = (risk as any)?.data || []
|
||||
const riskMeta = (risk as any)?.meta || { total: 0 }
|
||||
const demandData = (demand as any)?.data || []
|
||||
const rawDemandData = (demand as any)?.data || []
|
||||
const demandData = [...rawDemandData].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[demandSort]) || a[demandSort] || 0
|
||||
const bv = parseFloat(b[demandSort]) || b[demandSort] || 0
|
||||
if (typeof av === 'string') return demandOrder === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
|
||||
return demandOrder === 'asc' ? av - bv : bv - av
|
||||
})
|
||||
|
||||
const sharingColor = (type: string) => {
|
||||
if (type === '核心原料') return 'bg-red-100 text-red-700'
|
||||
@@ -96,6 +107,26 @@ export function SupplyTab() {
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="独有原料风险" subtitle="低收入SKU占用专用原料">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'high_risk', label: '高风险' },
|
||||
{ key: 'low_sales', label: '低销售额' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setRiskFilter(f.key); setRiskPage(1) }} className={`px-3 py-1 rounded text-xs ${riskFilter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'sales_amount', label: '销售额' },
|
||||
{ key: 'unique_material_count', label: '独有原料数' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setRiskSort(s.key); setRiskPage(1) }} className={`px-3 py-1 rounded text-xs ${riskSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setRiskOrder(riskOrder === 'asc' ? 'desc' : 'asc'); setRiskPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{riskOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={riskPage} pageSize={PAGE_SIZE} total={riskMeta.total} onPageChange={setRiskPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
@@ -151,6 +182,19 @@ export function SupplyTab() {
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="BOM驱动物料需求预测" subtitle="按历史销量×标准用量估算" defaultOpen={false}>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: 'estimated_demand', label: '需求量' },
|
||||
{ key: 'dish_count', label: '涉及菜品数' },
|
||||
{ key: 'material_name', label: '物料名' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setDemandSort(s.key); setDemandPage(1) }} className={`px-3 py-1 rounded text-xs ${demandSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setDemandOrder(demandOrder === 'asc' ? 'desc' : 'asc'); setDemandPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{demandOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination page={demandPage} pageSize={PAGE_SIZE} total={demandData.length} onPageChange={setDemandPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const SAFETY_COLORS: Record<string, string> = {
|
||||
'无法盈利': 'text-red-700 font-bold',
|
||||
'未达盈亏平衡': 'text-red-600 font-medium',
|
||||
'接近盈亏平衡': 'text-orange-600 font-medium',
|
||||
'安全边际较低': 'text-yellow-600',
|
||||
'安全边际充足': 'text-green-600',
|
||||
}
|
||||
|
||||
export function BreakEvenTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('safety_margin_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/break-even', page, sort, filter, order], queryFn: () => api.get(`/store-expense/break-even?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载盈亏平衡分析..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'safety_margin_pct', label: '安全边际率' },
|
||||
{ key: 'break_even_sales', label: '盈亏平衡额' },
|
||||
{ key: 'sales_gap', label: '销售缺口' },
|
||||
{ key: 'food_cost_rate_pct', label: '食材成本率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="盈亏平衡分析" subtitle="盈亏平衡销售额 = 固定费用 ÷ (1 - 食材成本率),安全边际 = (实收 - 平衡点) ÷ 平衡点">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'actual_food_cost', label: '食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
|
||||
{ key: 'food_cost_rate_pct', label: '食材成本率', align: 'right', render: (r) => formatPercent(r.food_cost_rate_pct) },
|
||||
{ key: 'operating_expense', label: '营业费用', align: 'right', render: (r) => formatCurrency(r.operating_expense) },
|
||||
{ key: 'expense_rate_pct', label: '费用率', align: 'right', render: (r) => formatPercent(r.expense_rate_pct) },
|
||||
{ key: 'break_even_sales', label: '盈亏平衡销售额', align: 'right', render: (r) => formatCurrency(r.break_even_sales) },
|
||||
{ key: 'sales_gap', label: '销售缺口', align: 'right', render: (r) => <span className={parseFloat(r.sales_gap) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.sales_gap)}</span> },
|
||||
{ key: 'safety_margin_pct', label: '安全边际率', align: 'right', render: (r) => <span className={parseFloat(r.safety_margin_pct) < 0 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.safety_margin_pct)}</span> },
|
||||
{ key: 'safety_status', label: '安全状态', render: (r) => <span className={SAFETY_COLORS[r.safety_status] || ''}>{r.safety_status}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function ContributionTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/contribution', page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-contribution?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载门店贡献利润..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
{ key: 'zero_sales', label: '零销售门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润' },
|
||||
{ key: 'contribution_variance', label: '贡献差异' },
|
||||
{ key: 'operating_expense_rate_pct', label: '费用率' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="门店贡献利润分析" subtitle="实收 - 食材成本 - 营业费用 = 门店贡献利润">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'theoretical_cost', label: '理论食材成本', align: 'right', render: (r) => formatCurrency(r.theoretical_cost) },
|
||||
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
|
||||
{ key: 'operating_expense', label: '营业费用', align: 'right', render: (r) => formatCurrency(r.operating_expense) },
|
||||
{ key: 'theoretical_store_contribution', label: '理论贡献', align: 'right', render: (r) => formatCurrency(r.theoretical_store_contribution) },
|
||||
{ key: 'theoretical_store_contribution_rate_pct', label: '理论贡献率', align: 'right', render: (r) => formatPercent(r.theoretical_store_contribution_rate_pct) },
|
||||
{ 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) => <span className={parseFloat(r.actual_store_contribution_rate_pct) < 0 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.actual_store_contribution_rate_pct)}</span> },
|
||||
{ key: 'contribution_variance', label: '贡献差异', align: 'right', render: (r) => <span className={parseFloat(r.contribution_variance) < 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(r.contribution_variance)}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function DeliveryTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('commission_to_delivery_sales_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/delivery', page, sort, filter, order], queryFn: () => api.get(`/store-expense/delivery-commission?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载外卖佣金分析..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'commission_to_delivery_sales_pct', label: '佣金率' },
|
||||
{ key: 'delivery_sales_share_pct', label: '外卖占比' },
|
||||
{ key: 'delivery_received', label: '外卖收入' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'combined_platform_cost_rate_pct', label: '综合成本率' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="外卖佣金分析" subtitle="外卖收入占比、佣金率及综合平台成本率">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'delivery_received', label: '外卖收入', align: 'right', render: (r) => formatCurrency(r.delivery_received) },
|
||||
{ key: 'delivery_sales_share_pct', label: '外卖占比', align: 'right', render: (r) => <span className={parseFloat(r.delivery_sales_share_pct) > 40 ? 'text-red-600 font-medium' : parseFloat(r.delivery_sales_share_pct) > 25 ? 'text-orange-600' : ''}>{formatPercent(r.delivery_sales_share_pct)}</span> },
|
||||
{ key: 'delivery_commission_expense', label: '佣金支出', align: 'right', render: (r) => formatCurrency(r.delivery_commission_expense) },
|
||||
{ key: 'commission_to_delivery_sales_pct', label: '佣金率', align: 'right', render: (r) => <span className={parseFloat(r.commission_to_delivery_sales_pct) > 25 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.commission_to_delivery_sales_pct)}</span> },
|
||||
{ key: 'combined_platform_cost_rate_pct', label: '综合平台成本率', align: 'right', render: (r) => formatPercent(r.combined_platform_cost_rate_pct) },
|
||||
{ key: 'delivery_type', label: '外卖类型' },
|
||||
{ key: 'suggestion', label: '建议' },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function EfficiencyTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('received_per_sqm')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/efficiency', page, sort, filter, order], queryFn: () => api.get(`/store-expense/efficiency?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载人效坪效数据..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
{ key: 'wage_rate_pct', label: '人工率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'bill_count', label: '账单数' },
|
||||
{ key: 'avg_ticket_size', label: '客单价' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="人效坪效分析" subtitle="坪效(元/㎡) + 人工成本率 + 客单价 + 营业效率评估">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => formatNumber(r.area_sqm) },
|
||||
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => <span className={parseFloat(r.received_per_sqm) < 1000 ? 'text-red-600 font-medium' : parseFloat(r.received_per_sqm) > 4000 ? 'text-green-600 font-medium' : ''}>{formatNumber(r.received_per_sqm)}</span> },
|
||||
{ key: 'wage_expense', label: '人工成本', align: 'right', render: (r) => formatCurrency(r.wage_expense) },
|
||||
{ key: 'wage_rate_pct', label: '人工成本率', align: 'right', render: (r) => <span className={parseFloat(r.wage_rate_pct) > 35 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.wage_rate_pct)}</span> },
|
||||
{ key: 'revenue_per_wage', label: '人效(元/元人工)', align: 'right', render: (r) => formatNumber(r.revenue_per_wage) },
|
||||
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
||||
{ key: 'avg_ticket_size', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_ticket_size) },
|
||||
{ key: 'efficiency_level', label: '效率等级', render: (r) => <span className={r.efficiency_level === '坪效偏低' || r.efficiency_level === '坪效极低' ? 'text-red-600' : r.efficiency_level === '坪效优秀' ? 'text-green-600' : ''}>{r.efficiency_level}</span> },
|
||||
{ key: 'suggestion', label: '建议' },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f97316', '#eab308', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#a3a3a3', '#f43f5e', '#6366f1', '#14b8a6', '#facc15', '#7c3aed', '#10b981', '#fb923c']
|
||||
|
||||
export function ExpenseOverviewTab() {
|
||||
const { data: overview, isLoading: l1 } = useQuery({ queryKey: ['se/overview'], queryFn: () => api.get('/store-expense/overview') })
|
||||
const { data: structure, isLoading: l2 } = useQuery({ queryKey: ['se/structure'], queryFn: () => api.get('/store-expense/expense-structure') })
|
||||
|
||||
if (l1 || l2) return <LoadingSpinner text="加载费用总览..." />
|
||||
|
||||
const ov = (overview as any)?.data || {}
|
||||
const structureData = (structure as any)?.data || []
|
||||
|
||||
const pieData = structureData.map((r: any) => ({ name: r.account_name, value: parseFloat(r.amount) }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard title="经营门店" value={ov.total_stores} unit="家" description={`${ov.profitable_stores}家盈利 / ${ov.loss_stores}家亏损`} />
|
||||
<MetricCard title="实收合计" value={ov.total_received} format="currency" description={`费用合计 ${formatCurrency(ov.total_expense)}`} />
|
||||
<MetricCard title="门店贡献利润" value={ov.total_contribution} format="currency" description={`贡献率 ${formatPercent(ov.overall_contribution_rate_pct)}`} />
|
||||
<MetricCard title="费用率" value={ov.overall_expense_rate_pct} format="percent" description={`人工 ${formatPercent(ov.overall_wage_rate_pct)} · 房租 ${formatPercent(ov.overall_rent_rate_pct)}`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<CollapsibleSection title="费用结构分析" subtitle="2026年4月各费用科目占比">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={120} label={(e: any) => e.name.length > 6 ? e.name.slice(0, 6) + '…' : e.name}>
|
||||
{pieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="费用科目明细" subtitle="按金额降序">
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
<th className="text-left p-2">费用科目</th>
|
||||
<th className="text-right p-2">金额</th>
|
||||
<th className="text-right p-2">占比</th>
|
||||
<th className="text-right p-2">门店数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{structureData.map((r: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="p-2">{r.account_name}</td>
|
||||
<td className="text-right p-2">{formatCurrency(r.amount)}</td>
|
||||
<td className="text-right p-2">{formatPercent(r.expense_share_pct)}</td>
|
||||
<td className="text-right p-2">{r.nonzero_cost_unit_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function FixedVariableTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('fixed_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/fixed-variable', page, sort, filter, order], queryFn: () => api.get(`/store-expense/fixed-variable?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载固定/变动费用拆分..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'fixed_rate_pct', label: '固定费用率' },
|
||||
{ key: 'variable_rate_pct', label: '变动费用率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'contribution_after_variable', label: '边际贡献' },
|
||||
{ key: 'break_even_sales', label: '盈亏平衡额' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="固定/变动费用拆分" subtitle="固定费用(租金+宿舍+维修50%) vs 变动费用(人工+水电+佣金+刷卡+维修50%)">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'fixed_expense', label: '固定费用', align: 'right', render: (r) => formatCurrency(r.fixed_expense) },
|
||||
{ key: 'fixed_rate_pct', label: '固定费用率', align: 'right', render: (r) => formatPercent(r.fixed_rate_pct) },
|
||||
{ key: 'variable_expense', label: '变动费用', align: 'right', render: (r) => formatCurrency(r.variable_expense) },
|
||||
{ key: 'variable_rate_pct', label: '变动费用率', align: 'right', render: (r) => formatPercent(r.variable_rate_pct) },
|
||||
{ key: 'contribution_after_variable', label: '边际贡献(扣变动)', align: 'right', render: (r) => <span className={parseFloat(r.contribution_after_variable) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.contribution_after_variable)}</span> },
|
||||
{ key: 'break_even_sales', label: '盈亏平衡销售额', align: 'right', render: (r) => formatCurrency(r.break_even_sales) },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
'P0': 'bg-red-100 text-red-700',
|
||||
'P1': 'bg-orange-100 text-orange-700',
|
||||
'P2': 'bg-yellow-100 text-yellow-700',
|
||||
'P3': 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
const LOSS_TYPE_COLORS: Record<string, string> = {
|
||||
'理论即亏损': 'text-red-600 font-medium',
|
||||
'实际超耗导致亏损': 'text-orange-600 font-medium',
|
||||
'费用过高导致亏损': 'text-yellow-600 font-medium',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
'收入极低': 'text-red-600 font-medium',
|
||||
'收入偏低': 'text-orange-600',
|
||||
'食材成本率过高': 'text-red-600 font-medium',
|
||||
'食材成本率偏高': 'text-orange-600',
|
||||
'人工成本率严重过高': 'text-red-600 font-medium',
|
||||
'人工成本率过高': 'text-red-600',
|
||||
'人工成本率偏高': 'text-orange-600',
|
||||
'租金占比严重过高': 'text-red-600 font-medium',
|
||||
'租金占比过高': 'text-red-600',
|
||||
'租金占比偏高': 'text-orange-600',
|
||||
'水电燃气率过高': 'text-red-600',
|
||||
'水电燃气率偏高': 'text-orange-600',
|
||||
'外卖依赖度过高': 'text-red-600 font-medium',
|
||||
'外卖占比较高': 'text-orange-600',
|
||||
'坪效极低': 'text-red-600 font-medium',
|
||||
'坪效偏低': 'text-orange-600',
|
||||
'租约即将到期': 'text-red-600 font-medium',
|
||||
}
|
||||
|
||||
interface StoreDetail {
|
||||
sales_store_name: string
|
||||
received: string
|
||||
actual_food_cost: string
|
||||
operating_expense: string
|
||||
actual_store_contribution: string
|
||||
actual_store_contribution_rate_pct: string
|
||||
wage_expense: string
|
||||
wage_rate_pct: string
|
||||
rent_expense: string
|
||||
rent_rate_pct: string
|
||||
utility_expense: string
|
||||
utility_rate_pct: string
|
||||
dorm_expense: string
|
||||
delivery_commission_expense: string
|
||||
delivery_received: string
|
||||
delivery_sales_share_pct: string
|
||||
area_sqm: string
|
||||
received_per_sqm: string
|
||||
bill_count: string
|
||||
lease_expiry_date: string
|
||||
theoretical_cost: string
|
||||
theoretical_store_contribution: string
|
||||
theoretical_store_contribution_rate_pct: string
|
||||
revenue_status: string
|
||||
food_cost_status: string
|
||||
wage_status: string
|
||||
rent_status: string
|
||||
utility_status: string
|
||||
delivery_status: string
|
||||
efficiency_status: string
|
||||
lease_status: string
|
||||
loss_type: string
|
||||
diagnosis_reasons: string
|
||||
diagnosis_suggestions: string
|
||||
suggested_actions: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
function StoreDetailModal({ store, onClose }: { store: StoreDetail | null, onClose: () => void }) {
|
||||
if (!store) return null
|
||||
|
||||
const statusItems = [
|
||||
{ label: '收入状态', value: store.revenue_status },
|
||||
{ label: '食材成本', value: store.food_cost_status },
|
||||
{ label: '人工成本', value: store.wage_status },
|
||||
{ label: '租金占比', value: store.rent_status },
|
||||
{ label: '水电燃气', value: store.utility_status },
|
||||
{ label: '外卖依赖', value: store.delivery_status },
|
||||
{ label: '坪效', value: store.efficiency_status },
|
||||
{ label: '租约', value: store.lease_status },
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ label: '实收', value: formatCurrency(store.received) },
|
||||
{ label: '食材成本', value: formatCurrency(store.actual_food_cost) },
|
||||
{ label: '营业费用', value: formatCurrency(store.operating_expense) },
|
||||
{ label: '贡献利润', value: formatCurrency(store.actual_store_contribution), red: true },
|
||||
{ label: '贡献率', value: formatPercent(store.actual_store_contribution_rate_pct), red: true },
|
||||
{ label: '理论贡献', value: formatCurrency(store.theoretical_store_contribution) },
|
||||
{ label: '理论贡献率', value: formatPercent(store.theoretical_store_contribution_rate_pct) },
|
||||
{ label: '人工成本', value: formatCurrency(store.wage_expense) },
|
||||
{ label: '人工成本率', value: formatPercent(store.wage_rate_pct) },
|
||||
{ label: '租金', value: formatCurrency(store.rent_expense) },
|
||||
{ label: '租金率', value: formatPercent(store.rent_rate_pct) },
|
||||
{ label: '水电燃气', value: formatCurrency(store.utility_expense) },
|
||||
{ label: '水电率', value: formatPercent(store.utility_rate_pct) },
|
||||
{ label: '宿舍费用', value: formatCurrency(store.dorm_expense) },
|
||||
{ label: '外卖佣金', value: formatCurrency(store.delivery_commission_expense) },
|
||||
{ label: '外卖收入', value: formatCurrency(store.delivery_received) },
|
||||
{ label: '外卖占比', value: formatPercent(store.delivery_sales_share_pct) },
|
||||
{ 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') : '-' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-card z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold">{store.sales_store_name}</h2>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[store.priority] || ''}`}>{store.priority}</span>
|
||||
<span className={`text-sm ${LOSS_TYPE_COLORS[store.loss_type] || ''}`}>{store.loss_type}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">关键指标</h3>
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{metrics.map((m, i) => (
|
||||
<div key={i} className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">{m.label}</p>
|
||||
<p className={`text-sm font-medium ${m.red ? 'text-red-600' : ''}`}>{m.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">诊断状态</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{statusItems.map((s, i) => (
|
||||
<div key={i} className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
<p className={`text-sm font-medium ${STATUS_COLORS[s.value] || ''}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">亏损原因分析</h3>
|
||||
<div className="rounded border p-3 bg-red-50">
|
||||
<p className="text-sm">{store.diagnosis_reasons}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">改进建议</h3>
|
||||
<div className="rounded border p-3 bg-blue-50">
|
||||
<p className="text-sm text-blue-700">{store.diagnosis_suggestions}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">建议动作</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{store.suggested_actions.split('、').map((action, i) => (
|
||||
<span key={i} className="px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">{action}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LossDiagnosisTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('actual_store_contribution')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const [selectedStore, setSelectedStore] = useState<StoreDetail | null>(null)
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/loss-diagnosis', page, sort, filter, order], queryFn: () => api.get(`/store-expense/loss-diagnosis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载亏损门店诊断..." />
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard title="亏损门店数" value={meta.total} unit="家" description="贡献利润 ≤ 0 的门店" />
|
||||
<MetricCard title="P0 紧急" value={p0Count} unit="家" description="贡献率 < -20%" />
|
||||
<MetricCard title="P1 整改" value={p1Count} unit="家" description="贡献率 -20% ~ -5%" />
|
||||
<MetricCard title="亏损金额合计" value={totalLoss} format="currency" description="当前页亏损门店贡献利润之和" />
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="亏损门店诊断" subtitle="点击门店名称查看详情">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'P0', label: 'P0 紧急' },
|
||||
{ key: 'P1', label: 'P1 整改' },
|
||||
{ key: 'P2', label: 'P2 观察' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'actual_store_contribution', label: '贡献利润' },
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'wage_rate_pct', label: '人工率' },
|
||||
{ key: 'rent_rate_pct', label: '租金率' },
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店', render: (r) => <button className="text-blue-600 hover:underline font-medium" onClick={() => setSelectedStore(r as StoreDetail)}>{r.sales_store_name}</button> },
|
||||
{ key: 'priority', label: '优先级', render: (r) => <span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[r.priority] || ''}`}>{r.priority}</span> },
|
||||
{ key: 'loss_type', label: '亏损类型', render: (r) => <span className={LOSS_TYPE_COLORS[r.loss_type] || ''}>{r.loss_type}</span> },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润', align: 'right', render: (r) => <span className="text-red-600 font-medium">{formatCurrency(r.actual_store_contribution)}</span> },
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.actual_store_contribution_rate_pct)}</span> },
|
||||
{ key: 'wage_rate_pct', label: '人工率', align: 'right', render: (r) => <span className={parseFloat(r.wage_rate_pct) > 35 ? 'text-red-600' : ''}>{formatPercent(r.wage_rate_pct)}</span> },
|
||||
{ key: 'rent_rate_pct', label: '租金率', align: 'right', render: (r) => <span className={parseFloat(r.rent_rate_pct) > 20 ? 'text-red-600' : ''}>{formatPercent(r.rent_rate_pct)}</span> },
|
||||
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => <span className={parseFloat(r.received_per_sqm) < 1000 ? 'text-red-600' : ''}>{formatNumber(r.received_per_sqm)}</span> },
|
||||
{ key: 'diagnosis_reasons', label: '亏损原因', render: (r) => <span className="text-xs">{r.diagnosis_reasons}</span> },
|
||||
{ key: 'suggested_actions', label: '建议动作', render: (r) => <span className="text-xs font-medium">{r.suggested_actions}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<StoreDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
'即将到期': 'text-red-600 font-medium',
|
||||
'年内到期': 'text-orange-600 font-medium',
|
||||
'明年上半年到期': 'text-yellow-600',
|
||||
'租金占比偏高': 'text-orange-600',
|
||||
'正常': '',
|
||||
}
|
||||
|
||||
export function RentRiskTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('rent_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [riskOnly, setRiskOnly] = useState(false)
|
||||
const [order, setOrder] = useState('desc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/rent', page, sort, filter, riskOnly, order], queryFn: () => api.get(`/store-expense/rent-risk?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&risk_only=${riskOnly}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载房租及租约风险..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
{ key: 'zero_sales', label: '零销售门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'rent_rate_pct', label: '租金率' },
|
||||
{ key: 'rent_expense', label: '租金' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
{ key: 'lease_expiry_date', label: '租约到期' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="房租及租约风险" subtitle="租金占比 + 租约到期日,识别续租和关停风险门店">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setRiskOnly(!riskOnly); setPage(1) }} className={`px-3 py-1 rounded text-xs ${riskOnly ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
|
||||
{riskOnly ? '显示全部' : '仅显示风险门店'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'rent_expense', label: '租金', align: 'right', render: (r) => formatCurrency(r.rent_expense) },
|
||||
{ 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: 'risk_level', label: '风险等级', render: (r) => <span className={RISK_COLORS[r.risk_level] || ''}>{r.risk_level}</span> },
|
||||
{ key: 'suggestion', label: '建议' },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const EVAL_COLORS: Record<string, string> = {
|
||||
'关停评估': 'bg-red-100 text-red-700',
|
||||
'关停或迁址评估': 'bg-red-100 text-red-700',
|
||||
'改造评估': 'bg-orange-100 text-orange-700',
|
||||
'续租评估': 'bg-yellow-100 text-yellow-700',
|
||||
'关注观察': 'bg-blue-100 text-blue-700',
|
||||
'正常经营': 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
'P0': 'text-red-600 font-medium',
|
||||
'P1': 'text-orange-600 font-medium',
|
||||
'P2': 'text-yellow-600',
|
||||
'P3': 'text-blue-600',
|
||||
'P4': 'text-green-600',
|
||||
}
|
||||
|
||||
interface EvalStoreDetail {
|
||||
sales_store_name: string
|
||||
received: string
|
||||
actual_food_cost: string
|
||||
operating_expense: string
|
||||
actual_store_contribution: string
|
||||
actual_store_contribution_rate_pct: string
|
||||
wage_rate_pct: string
|
||||
rent_rate_pct: string
|
||||
received_per_sqm: string
|
||||
area_sqm: string
|
||||
lease_expiry_date: string
|
||||
theoretical_store_contribution: string
|
||||
evaluation_type: string
|
||||
evaluation_detail: string
|
||||
evaluation_suggestion: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
function EvalDetailModal({ store, onClose }: { store: EvalStoreDetail | null, onClose: () => void }) {
|
||||
if (!store) return null
|
||||
|
||||
const metrics = [
|
||||
{ label: '实收', value: formatCurrency(store.received) },
|
||||
{ label: '食材成本', value: formatCurrency(store.actual_food_cost) },
|
||||
{ label: '营业费用', value: formatCurrency(store.operating_expense) },
|
||||
{ label: '贡献利润', value: formatCurrency(store.actual_store_contribution), red: parseFloat(store.actual_store_contribution) < 0 },
|
||||
{ label: '贡献率', value: formatPercent(store.actual_store_contribution_rate_pct), red: parseFloat(store.actual_store_contribution_rate_pct) < 0 },
|
||||
{ label: '理论贡献', value: formatCurrency(store.theoretical_store_contribution) },
|
||||
{ label: '人工成本率', value: formatPercent(store.wage_rate_pct), warn: parseFloat(store.wage_rate_pct) > 30 },
|
||||
{ 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') : '-' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-card z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold">{store.sales_store_name}</h2>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${EVAL_COLORS[store.evaluation_type] || ''}`}>{store.evaluation_type}</span>
|
||||
<span className={`text-sm ${PRIORITY_COLORS[store.priority] || ''}`}>{store.priority}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">关键指标</h3>
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{metrics.map((m, i) => (
|
||||
<div key={i} className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">{m.label}</p>
|
||||
<p className={`text-sm font-medium ${m.red ? 'text-red-600' : m.warn ? 'text-orange-600' : ''}`}>{m.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">评估详情</h3>
|
||||
<div className="rounded border p-3 bg-muted/50">
|
||||
<p className="text-sm">{store.evaluation_detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">建议</h3>
|
||||
<div className="rounded border p-3 bg-blue-50">
|
||||
<p className="text-sm text-blue-700">{store.evaluation_suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StoreEvaluationTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const [selectedStore, setSelectedStore] = useState<EvalStoreDetail | null>(null)
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/evaluation', page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-evaluation?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载门店评估..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
const evalStats = (data as any)?.meta?.evalStats || {}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard title="关停评估" value={parseInt(evalStats['关停评估'] || '0')} unit="家" description="收入极低或严重亏损" />
|
||||
<MetricCard title="改造评估" value={parseInt(evalStats['改造评估'] || '0')} unit="家" description="坪效低或人工率偏高" />
|
||||
<MetricCard title="续租评估" value={parseInt(evalStats['续租评估'] || '0')} unit="家" description="租约即将到期" />
|
||||
<MetricCard title="关注观察" value={parseInt(evalStats['关注观察'] || '0')} unit="家" description="亏损或盈利能力薄弱" />
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="门店关停/续租/改造评估" subtitle="点击门店名称查看详情">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润' },
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
{ key: 'wage_rate_pct', label: '人工率' },
|
||||
{ key: 'rent_rate_pct', label: '租金率' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店', render: (r) => <button className="text-blue-600 hover:underline font-medium" onClick={() => setSelectedStore(r as EvalStoreDetail)}>{r.sales_store_name}</button> },
|
||||
{ key: 'priority', label: '优先级', render: (r) => <span className={PRIORITY_COLORS[r.priority] || ''}>{r.priority}</span> },
|
||||
{ key: 'evaluation_type', label: '评估类型', render: (r) => <span className={`px-2 py-0.5 rounded text-xs font-medium ${EVAL_COLORS[r.evaluation_type] || ''}`}>{r.evaluation_type}</span> },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ 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: 'evaluation_detail', label: '评估详情', render: (r) => <span className="text-xs">{r.evaluation_detail}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<EvalDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function StoreRankingTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('operating_expense_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('desc')
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/ranking', page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载门店费用率排名..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
{ key: 'zero_sales', label: '零销售门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'operating_expense_rate_pct', label: '费用率' },
|
||||
{ key: 'wage_rate_pct', label: '人工率' },
|
||||
{ key: 'rent_rate_pct', label: '租金率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润' },
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="门店费用率排名" subtitle="按费用率降序,支持筛选和排序">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'operating_expense', label: '费用合计', align: 'right', render: (r) => formatCurrency(r.operating_expense) },
|
||||
{ key: 'operating_expense_rate_pct', label: '费用率', align: 'right', render: (r) => <span className={parseFloat(r.operating_expense_rate_pct) > 50 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.operating_expense_rate_pct)}</span> },
|
||||
{ key: 'wage_rate_pct', label: '人工率', align: 'right', render: (r) => <span className={parseFloat(r.wage_rate_pct) > 30 ? 'text-red-600' : ''}>{formatPercent(r.wage_rate_pct)}</span> },
|
||||
{ key: 'rent_rate_pct', label: '租金率', align: 'right', render: (r) => <span className={parseFloat(r.rent_rate_pct) > 20 ? 'text-red-600' : ''}>{formatPercent(r.rent_rate_pct)}</span> },
|
||||
{ key: 'utility_rate_pct', label: '水电率', align: 'right', render: (r) => formatPercent(r.utility_rate_pct) },
|
||||
{ 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) },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { Tabs } from '@/components/Tabs'
|
||||
import { ExpenseOverviewTab } from '@/components/store-expense/ExpenseOverviewTab'
|
||||
import { StoreRankingTab } from '@/components/store-expense/StoreRankingTab'
|
||||
import { ContributionTab } from '@/components/store-expense/ContributionTab'
|
||||
import { RentRiskTab } from '@/components/store-expense/RentRiskTab'
|
||||
import { DeliveryTab } from '@/components/store-expense/DeliveryTab'
|
||||
import { EfficiencyTab } from '@/components/store-expense/EfficiencyTab'
|
||||
import { FixedVariableTab } from '@/components/store-expense/FixedVariableTab'
|
||||
import { BreakEvenTab } from '@/components/store-expense/BreakEvenTab'
|
||||
import { LossDiagnosisTab } from '@/components/store-expense/LossDiagnosisTab'
|
||||
import { StoreEvaluationTab } from '@/components/store-expense/StoreEvaluationTab'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: '费用总览' },
|
||||
{ key: 'ranking', label: '门店费用率排名' },
|
||||
{ key: 'contribution', label: '门店贡献利润' },
|
||||
{ key: 'rent', label: '房租及租约风险' },
|
||||
{ key: 'delivery', label: '外卖佣金分析' },
|
||||
{ key: 'efficiency', label: '人效坪效' },
|
||||
{ key: 'fixed-variable', label: '固定/变动费用' },
|
||||
{ key: 'break-even', label: '盈亏平衡分析' },
|
||||
{ key: 'loss-diagnosis', label: '亏损门店诊断' },
|
||||
{ key: 'evaluation', label: '关停/续租/改造评估' },
|
||||
]
|
||||
|
||||
export function StoreExpensePage() {
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">门店营业费用分析</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">2026年4月 · 89家经营门店 · 费用合计 ¥31,811,801</p>
|
||||
</div>
|
||||
|
||||
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<div>
|
||||
{activeTab === 'overview' && <ExpenseOverviewTab />}
|
||||
{activeTab === 'ranking' && <StoreRankingTab />}
|
||||
{activeTab === 'contribution' && <ContributionTab />}
|
||||
{activeTab === 'rent' && <RentRiskTab />}
|
||||
{activeTab === 'delivery' && <DeliveryTab />}
|
||||
{activeTab === 'efficiency' && <EfficiencyTab />}
|
||||
{activeTab === 'fixed-variable' && <FixedVariableTab />}
|
||||
{activeTab === 'break-even' && <BreakEvenTab />}
|
||||
{activeTab === 'loss-diagnosis' && <LossDiagnosisTab />}
|
||||
{activeTab === 'evaluation' && <StoreEvaluationTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import authRoutes from './routes/auth.js'
|
||||
import dataRoutes from './routes/data.js'
|
||||
import taskRoutes from './routes/tasks.js'
|
||||
import costAnalysisRoutes from './routes/cost-analysis.js'
|
||||
import storeExpenseRoutes from './routes/store-expense.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = parseInt(process.env.PORT || '3333')
|
||||
@@ -33,6 +34,7 @@ app.use((req, res, next) => {
|
||||
app.use('/api', dataRoutes)
|
||||
app.use('/api/tasks', taskRoutes)
|
||||
app.use('/api/cost-analysis', costAnalysisRoutes)
|
||||
app.use('/api/store-expense', storeExpenseRoutes)
|
||||
|
||||
app.use(notFoundHandler)
|
||||
app.use(errorHandler)
|
||||
|
||||
@@ -137,9 +137,19 @@ router.get('/profitability', async (req: AuthRequest, res) => {
|
||||
const category = req.query.category as string
|
||||
const menuType = req.query.type as string
|
||||
|
||||
const sort = (req.query.sort as string) || 'sales_amount'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = 'WHERE actual_margin_rate_pct IS NOT NULL'
|
||||
const params: any[] = []
|
||||
if (category) { params.push(category); where += ` AND category_level1 = $${params.length}` }
|
||||
if (filter === 'profitable') { where += ` AND actual_margin_rate_pct > 0` }
|
||||
else if (filter === 'loss') { where += ` AND actual_margin_rate_pct <= 0` }
|
||||
else if (filter === 'high_variance') { where += ` AND cost_variance_amount > 0` }
|
||||
|
||||
const validSorts = ['sales_amount', 'sales_quantity', 'theoretical_margin_rate_pct', 'actual_margin_rate_pct', 'cost_variance_amount', 'theoretical_cost', 'actual_cost', 'price']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'sales_amount'
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM public.dish_cost_analysis_summary ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
@@ -155,7 +165,7 @@ router.get('/profitability', async (req: AuthRequest, res) => {
|
||||
round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
|
||||
round(sales_amount / nullif(sum(sales_amount) OVER (), 0) * 100, 2) AS revenue_contribution
|
||||
FROM public.dish_cost_analysis_summary ${where}
|
||||
ORDER BY sales_amount DESC LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`, params)
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -310,6 +320,17 @@ router.get('/bom-overview', async (req: AuthRequest, res) => {
|
||||
router.get('/bom-complexity', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'material_count'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let having = ''
|
||||
if (filter === 'high_loss') { having = 'HAVING count(*) FILTER (WHERE b.waste_rate > 20) > 0' }
|
||||
else if (filter === 'unique_heavy') { having = 'HAVING count(*) FILTER (WHERE ref_count = 1) > 3' }
|
||||
|
||||
const validSorts = ['material_count', 'semi_finished_count', 'unique_material_count', 'high_loss_count', 'zero_actual_count']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'material_count'
|
||||
|
||||
const countResult = await query(`SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
@@ -332,7 +353,8 @@ router.get('/bom-complexity', async (req: AuthRequest, res) => {
|
||||
JOIN analytics.dim_material dm ON dm.material_code = b.material_code
|
||||
LEFT JOIN (SELECT material_code, count(DISTINCT sku_code) AS ref_count FROM analytics.fact_recipe_bom GROUP BY material_code) r ON r.material_code = b.material_code
|
||||
GROUP BY b.sku_code, s.standard_name, s.category_l1
|
||||
ORDER BY material_count DESC LIMIT $1 OFFSET $2
|
||||
${having}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -372,6 +394,12 @@ router.get('/bom-high-loss', async (req: AuthRequest, res) => {
|
||||
router.get('/bom-missing', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'sales_amount'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
const validSorts = ['sales_amount', 'sales_quantity', 'standard_name', 'category_l1']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'sales_amount'
|
||||
|
||||
const countResult = await query(`
|
||||
SELECT count(*) FROM analytics.dim_sku s
|
||||
WHERE s.sku_code NOT IN (SELECT DISTINCT sku_code FROM analytics.fact_recipe_bom)
|
||||
@@ -385,7 +413,7 @@ router.get('/bom-missing', async (req: AuthRequest, res) => {
|
||||
FROM analytics.dim_sku s
|
||||
LEFT JOIN public.dish_cost_analysis_summary d ON d.dish_code = s.sku_code
|
||||
WHERE s.sku_code NOT IN (SELECT DISTINCT sku_code FROM analytics.fact_recipe_bom)
|
||||
ORDER BY sales_amount DESC LIMIT $1 OFFSET $2
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -449,6 +477,17 @@ router.get('/material-sharing', async (req: AuthRequest, res) => {
|
||||
router.get('/unique-material-risk', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'sales_amount'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let having = ''
|
||||
if (filter === 'high_risk') { having = 'HAVING COALESCE(d.sales_amount, 0) < 5000 AND count(*) > 3' }
|
||||
else if (filter === 'low_sales') { having = 'HAVING COALESCE(d.sales_amount, 0) < 5000' }
|
||||
|
||||
const validSorts = ['sales_amount', 'unique_material_count', 'standard_name', 'category_l1']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'sales_amount'
|
||||
|
||||
const countResult = await query(`
|
||||
SELECT count(DISTINCT b.sku_code) FROM analytics.fact_recipe_bom b
|
||||
WHERE b.material_code IN (SELECT material_code FROM analytics.fact_recipe_bom GROUP BY material_code HAVING count(DISTINCT sku_code) = 1)
|
||||
@@ -472,7 +511,8 @@ router.get('/unique-material-risk', async (req: AuthRequest, res) => {
|
||||
LEFT JOIN public.dish_cost_analysis_summary d ON d.dish_code = b.sku_code
|
||||
WHERE b.material_code IN (SELECT material_code FROM unique_materials)
|
||||
GROUP BY b.sku_code, s.standard_name, s.category_l1, d.sales_amount
|
||||
ORDER BY sales_amount ASC, unique_material_count DESC LIMIT $1 OFFSET $2
|
||||
${having}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -561,6 +601,17 @@ router.get('/packaging-overview', async (req: AuthRequest, res) => {
|
||||
router.get('/packaging-detail', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'total_cost'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let having = ''
|
||||
if (filter === 'high_loss') { having = 'HAVING sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -20' }
|
||||
else if (filter === 'normal') { having = 'HAVING sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 >= -5' }
|
||||
|
||||
const validSorts = ['total_cost', 'total_qty', 'loss_qty', 'avg_loss_rate', 'dish_count', 'material_name']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'total_cost'
|
||||
|
||||
const countResult = await query(`
|
||||
SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail
|
||||
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
|
||||
@@ -582,7 +633,8 @@ router.get('/packaging-detail', async (req: AuthRequest, res) => {
|
||||
FROM public.dish_cost_analysis_material_detail
|
||||
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
|
||||
GROUP BY material_name
|
||||
ORDER BY total_cost DESC LIMIT $1 OFFSET $2
|
||||
${having}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -618,6 +670,12 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
|
||||
router.get('/unmatched-dishes', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'sales_amount'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
const validSorts = ['sales_amount', 'dish_name', 'dish_code', 'category_level1']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'sales_amount'
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = false`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
@@ -626,7 +684,7 @@ router.get('/unmatched-dishes', async (req: AuthRequest, res) => {
|
||||
round(sales_amount::numeric, 2) AS sales_amount
|
||||
FROM analytics.v_dish_cost_analysis_latest_summary
|
||||
WHERE matched_to_dish_sales_by_name = false
|
||||
ORDER BY sales_amount DESC NULLS LAST LIMIT $1 OFFSET $2
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -638,6 +696,12 @@ router.get('/unmatched-dishes', async (req: AuthRequest, res) => {
|
||||
router.get('/unmatched-materials', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'dish_count'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
const validSorts = ['dish_count', 'loss_amount', 'material_name', 'material_type']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'dish_count'
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = false`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
@@ -648,7 +712,7 @@ router.get('/unmatched-materials', async (req: AuthRequest, res) => {
|
||||
FROM analytics.v_dish_cost_analysis_latest_material_detail
|
||||
WHERE matched_to_inventory_by_name = false
|
||||
GROUP BY material_name, material_type
|
||||
ORDER BY dish_count DESC LIMIT $1 OFFSET $2
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -700,7 +764,20 @@ router.get('/store-map', async (req: AuthRequest, res) => {
|
||||
router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.v_store_theoretical_actual_cost_april`)
|
||||
const sort = (req.query.sort as string) || 'food_cost_variance'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = ''
|
||||
if (filter === 'red') { where = "WHERE variance_level = '红色-严重超耗'" }
|
||||
else if (filter === 'orange') { where = "WHERE variance_level = '橙色-明显超耗'" }
|
||||
else if (filter === 'green') { where = "WHERE variance_level = '绿色-基本正常'" }
|
||||
else if (filter === 'gray') { where = "WHERE variance_level = '灰色-口径异常'" }
|
||||
|
||||
const validSorts = ['food_cost_variance', 'theoretical_cost_rate_pct', 'actual_food_cost_rate_pct', 'variance_to_theoretical_pct', 'negative_item_lines', 'store_name']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'food_cost_variance'
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.v_store_theoretical_actual_cost_april ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const result = await query(`
|
||||
@@ -712,7 +789,8 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
negative_item_lines,
|
||||
variance_level
|
||||
FROM analytics.v_store_theoretical_actual_cost_april
|
||||
ORDER BY food_cost_variance DESC LIMIT $1 OFFSET $2
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -835,18 +913,30 @@ router.get('/diagnosis', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const priority = req.query.priority as string
|
||||
const sort = (req.query.sort as string) || 'priority'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
let where = 'WHERE 1=1'
|
||||
const params: any[] = []
|
||||
if (priority) { params.push(priority); where += ` AND priority = $${params.length}` }
|
||||
|
||||
const validSorts = ['priority', 'cost_variance_amount', 'sales_amount', 'actual_margin_pct', 'theoretical_margin_pct', 'dish_name']
|
||||
let sortExpr: string
|
||||
if (sort === 'priority') {
|
||||
sortExpr = `CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END ${order}`
|
||||
} else if (validSorts.includes(sort)) {
|
||||
sortExpr = `${sort} ${order} NULLS LAST`
|
||||
} else {
|
||||
sortExpr = `CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END ${order}`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM public.dish_diagnosis_snapshot ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
params.push(pageSize, offset)
|
||||
const result = await query(`
|
||||
SELECT * FROM public.dish_diagnosis_snapshot ${where}
|
||||
ORDER BY CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END, cost_variance_amount DESC NULLS LAST
|
||||
ORDER BY ${sortExpr}, cost_variance_amount DESC NULLS LAST
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`, params)
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
@@ -860,18 +950,23 @@ router.get('/adjustment', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const status = req.query.status as string
|
||||
const sort = (req.query.sort as string) || 'effective_date'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
let where = 'WHERE 1=1'
|
||||
const params: any[] = []
|
||||
if (status) { params.push(status); where += ` AND status = $${params.length}` }
|
||||
|
||||
const validSorts = ['effective_date', 'created_at', 'dish_name', 'adjustment_type', 'before_theoretical_margin', 'after_target_margin']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'effective_date'
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM public.dish_adjustment_log ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
params.push(pageSize, offset)
|
||||
const result = await query(`
|
||||
SELECT * FROM public.dish_adjustment_log ${where}
|
||||
ORDER BY effective_date DESC, created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST, created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`, params)
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
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()
|
||||
|
||||
// ============ Tab1: 费用总览 ============
|
||||
|
||||
// 费用总览指标
|
||||
router.get('/overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT
|
||||
count(*) AS total_stores,
|
||||
count(*) FILTER (WHERE actual_store_contribution > 0) AS profitable_stores,
|
||||
count(*) FILTER (WHERE actual_store_contribution <= 0 AND received > 0) AS loss_stores,
|
||||
count(*) FILTER (WHERE received = 0) AS zero_sales_stores,
|
||||
round(sum(received)::numeric, 2) AS total_received,
|
||||
round(sum(operating_expense)::numeric, 2) AS total_expense,
|
||||
round(sum(wage_expense)::numeric, 2) AS total_wage,
|
||||
round(sum(rent_expense)::numeric, 2) AS total_rent,
|
||||
round(sum(utility_expense)::numeric, 2) AS total_utility,
|
||||
round(sum(dorm_expense)::numeric, 2) AS total_dorm,
|
||||
round(sum(delivery_commission_expense)::numeric, 2) AS total_commission,
|
||||
round(sum(card_fee_expense)::numeric, 2) AS total_card_fee,
|
||||
round(sum(repair_clean_expense)::numeric, 2) AS total_repair,
|
||||
round(sum(actual_food_cost)::numeric, 2) AS total_food_cost,
|
||||
round(sum(actual_store_contribution)::numeric, 2) AS total_contribution,
|
||||
round(sum(area_sqm)::numeric, 2) AS total_area,
|
||||
round((1 - sum(operating_expense) / nullif(sum(received), 0)) * 100, 2) AS overall_expense_rate_pct,
|
||||
round((1 - (sum(actual_food_cost) + sum(operating_expense)) / nullif(sum(received), 0)) * 100, 2) AS overall_contribution_rate_pct,
|
||||
round(sum(wage_expense) / nullif(sum(received), 0) * 100, 2) AS overall_wage_rate_pct,
|
||||
round(sum(rent_expense) / nullif(sum(received), 0) * 100, 2) AS overall_rent_rate_pct,
|
||||
round(sum(utility_expense) / nullif(sum(received), 0) * 100, 2) AS overall_utility_rate_pct
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
WHERE report_month = DATE '2026-04-01'
|
||||
`)
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 费用结构分析
|
||||
router.get('/expense-structure', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT account_name,
|
||||
round(amount::numeric, 2) AS amount,
|
||||
round(expense_share_pct::numeric, 2) AS expense_share_pct,
|
||||
nonzero_cost_unit_count
|
||||
FROM analytics.v_operating_expense_account_monthly
|
||||
WHERE report_month = DATE '2026-04-01'
|
||||
ORDER BY amount DESC
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab2: 门店费用率排名 ============
|
||||
|
||||
// 门店费用率排名
|
||||
router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'operating_expense_rate_pct'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01'`
|
||||
const params: any[] = []
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
} else if (filter === 'zero_sales') {
|
||||
where += ` AND received = 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['operating_expense_rate_pct', 'wage_rate_pct', 'rent_rate_pct', 'utility_rate_pct', 'received', 'operating_expense', 'actual_store_contribution', 'actual_store_contribution_rate_pct', 'received_per_sqm']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'operating_expense_rate_pct'
|
||||
|
||||
const result = await query(`
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round(operating_expense::numeric, 2) AS operating_expense,
|
||||
round(operating_expense_rate_pct::numeric, 2) AS operating_expense_rate_pct,
|
||||
round(wage_expense::numeric, 2) AS wage_expense,
|
||||
round(wage_rate_pct::numeric, 2) AS wage_rate_pct,
|
||||
round(rent_expense::numeric, 2) AS rent_expense,
|
||||
round(rent_rate_pct::numeric, 2) AS rent_rate_pct,
|
||||
round(utility_expense::numeric, 2) AS utility_expense,
|
||||
round(utility_rate_pct::numeric, 2) AS utility_rate_pct,
|
||||
round(actual_food_cost::numeric, 2) AS actual_food_cost,
|
||||
round(actual_store_contribution::numeric, 2) AS actual_store_contribution,
|
||||
round(actual_store_contribution_rate_pct::numeric, 2) AS actual_store_contribution_rate_pct,
|
||||
round(area_sqm::numeric, 2) AS area_sqm,
|
||||
round(received_per_sqm::numeric, 2) AS received_per_sqm,
|
||||
lease_expiry_date
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab3: 门店贡献利润 ============
|
||||
|
||||
// 门店贡献利润分析
|
||||
router.get('/store-contribution', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'actual_store_contribution_rate_pct'
|
||||
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01'`
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
} else if (filter === 'zero_sales') {
|
||||
where += ` AND received = 0`
|
||||
} else {
|
||||
where += ` AND received > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['received', 'actual_store_contribution', 'actual_store_contribution_rate_pct', 'theoretical_store_contribution', 'theoretical_store_contribution_rate_pct', 'contribution_variance', 'operating_expense_rate_pct', 'wage_rate_pct', 'rent_rate_pct', 'received_per_sqm']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'actual_store_contribution_rate_pct'
|
||||
|
||||
const result = await query(`
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round(theoretical_cost::numeric, 2) AS theoretical_cost,
|
||||
round(actual_food_cost::numeric, 2) AS actual_food_cost,
|
||||
round(operating_expense::numeric, 2) AS operating_expense,
|
||||
round(theoretical_store_contribution::numeric, 2) AS theoretical_store_contribution,
|
||||
round(theoretical_store_contribution_rate_pct::numeric, 2) AS theoretical_store_contribution_rate_pct,
|
||||
round(actual_store_contribution::numeric, 2) AS actual_store_contribution,
|
||||
round(actual_store_contribution_rate_pct::numeric, 2) AS actual_store_contribution_rate_pct,
|
||||
round((actual_store_contribution - theoretical_store_contribution)::numeric, 2) AS contribution_variance
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab4: 房租及租约风险 ============
|
||||
|
||||
// 房租及租约风险
|
||||
router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'rent_rate_pct'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const riskOnly = req.query.risk_only === 'true'
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND rent_expense IS NOT NULL`
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
} else if (filter === 'zero_sales') {
|
||||
where += ` AND received = 0`
|
||||
}
|
||||
if (riskOnly) {
|
||||
where += ` AND (lease_expiry_date <= DATE '2026-12-31' OR rent_rate_pct > 20)`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['rent_rate_pct', 'rent_expense', 'received', 'received_per_sqm', 'lease_expiry_date', 'operating_expense_rate_pct', 'actual_store_contribution']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'rent_rate_pct'
|
||||
const orderBy = sortCol === 'lease_expiry_date'
|
||||
? `CASE WHEN lease_expiry_date IS NULL THEN 1 ELSE 0 END, lease_expiry_date ASC NULLS LAST`
|
||||
: `${sortCol} ${order} NULLS LAST`
|
||||
|
||||
const result = await query(`
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round(rent_expense::numeric, 2) AS rent_expense,
|
||||
round(rent_rate_pct::numeric, 2) AS rent_rate_pct,
|
||||
round(area_sqm::numeric, 2) AS area_sqm,
|
||||
round(received_per_sqm::numeric, 2) AS received_per_sqm,
|
||||
lease_expiry_date,
|
||||
CASE
|
||||
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '即将到期'
|
||||
WHEN lease_expiry_date <= DATE '2026-12-31' THEN '年内到期'
|
||||
WHEN lease_expiry_date <= DATE '2027-06-30' THEN '明年上半年到期'
|
||||
WHEN rent_rate_pct > 20 THEN '租金占比偏高'
|
||||
ELSE '正常'
|
||||
END AS risk_level,
|
||||
CASE
|
||||
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '需立即启动续租谈判或关停评估'
|
||||
WHEN lease_expiry_date <= DATE '2026-12-31' THEN '需提前规划续租或迁址方案'
|
||||
WHEN rent_rate_pct > 25 THEN '租金严重偏高,建议谈判降租或迁址'
|
||||
WHEN rent_rate_pct > 20 THEN '租金占比偏高,关注续租条件'
|
||||
ELSE '保持关注'
|
||||
END AS suggestion
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab5: 外卖佣金分析 ============
|
||||
|
||||
// 外卖佣金分析
|
||||
router.get('/delivery-commission', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'commission_to_delivery_sales_pct'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND delivery_received > 0`
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['commission_to_delivery_sales_pct', 'delivery_sales_share_pct', 'delivery_received', 'delivery_commission_expense', 'combined_platform_cost_rate_pct', 'received', 'actual_store_contribution']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'commission_to_delivery_sales_pct'
|
||||
|
||||
const result = await query(`
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round(delivery_received::numeric, 2) AS delivery_received,
|
||||
round(delivery_sales_share_pct::numeric, 2) AS delivery_sales_share_pct,
|
||||
round(delivery_commission_expense::numeric, 2) AS delivery_commission_expense,
|
||||
round(commission_to_delivery_sales_pct::numeric, 2) AS commission_to_delivery_sales_pct,
|
||||
round(combined_platform_cost_rate_pct::numeric, 2) AS combined_platform_cost_rate_pct,
|
||||
CASE
|
||||
WHEN delivery_sales_share_pct > 40 THEN '重度依赖外卖'
|
||||
WHEN delivery_sales_share_pct > 25 THEN '外卖占比较高'
|
||||
WHEN delivery_sales_share_pct > 10 THEN '外卖占比正常'
|
||||
ELSE '以堂食为主'
|
||||
END AS delivery_type,
|
||||
CASE
|
||||
WHEN commission_to_delivery_sales_pct > 25 THEN '佣金率偏高,建议优化平台策略'
|
||||
WHEN delivery_sales_share_pct > 40 THEN '外卖依赖度高,建议提升堂食客流'
|
||||
WHEN combined_platform_cost_rate_pct > 30 THEN '综合平台成本率高,需评估外卖盈利性'
|
||||
ELSE '外卖运营正常'
|
||||
END AS suggestion
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab6: 人效坪效 ============
|
||||
|
||||
// 人效坪效分析
|
||||
router.get('/efficiency', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'received_per_sqm'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0 AND area_sqm IS NOT NULL`
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['received_per_sqm', 'wage_rate_pct', 'received', 'wage_expense', 'bill_count', 'avg_ticket_size', 'actual_store_contribution', 'operating_expense_rate_pct']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'received_per_sqm'
|
||||
|
||||
const result = await query(`
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round(area_sqm::numeric, 2) AS area_sqm,
|
||||
round(received_per_sqm::numeric, 2) AS received_per_sqm,
|
||||
round(wage_expense::numeric, 2) AS wage_expense,
|
||||
round(wage_rate_pct::numeric, 2) AS wage_rate_pct,
|
||||
round(received / nullif(wage_expense, 0)::numeric, 2) AS revenue_per_wage,
|
||||
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 < 1000 THEN '坪效偏低'
|
||||
WHEN received_per_sqm < 2000 THEN '坪效一般'
|
||||
WHEN received_per_sqm < 4000 THEN '坪效良好'
|
||||
ELSE '坪效优秀'
|
||||
END AS efficiency_level,
|
||||
CASE
|
||||
WHEN wage_rate_pct > 35 THEN '人工成本率偏高,需优化排班或提升营业额'
|
||||
WHEN received_per_sqm < 1000 AND area_sqm > 400 THEN '面积大产出低,考虑缩减面积或改造'
|
||||
WHEN wage_rate_pct > 25 AND wage_rate_pct <= 35 THEN '人工成本率中等偏高,关注人效'
|
||||
ELSE '运营效率正常'
|
||||
END AS suggestion
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab7: 固定/变动费用拆分 ============
|
||||
|
||||
// 固定/变动费用拆分
|
||||
router.get('/fixed-variable', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'fixed_rate_pct'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['fixed_rate_pct', 'variable_rate_pct', 'received', 'fixed_expense', 'variable_expense', 'contribution_after_variable', 'break_even_sales', 'actual_store_contribution', 'operating_expense_rate_pct']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'fixed_rate_pct'
|
||||
|
||||
const result = await query(`
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round((rent_expense + dorm_expense + repair_clean_expense * 0.5)::numeric, 2) AS fixed_expense,
|
||||
round((wage_expense + utility_expense + delivery_commission_expense + card_fee_expense + repair_clean_expense * 0.5)::numeric, 2) AS variable_expense,
|
||||
round(rent_expense::numeric, 2) AS rent_expense,
|
||||
round(dorm_expense::numeric, 2) AS dorm_expense,
|
||||
round(wage_expense::numeric, 2) AS wage_expense,
|
||||
round(utility_expense::numeric, 2) AS utility_expense,
|
||||
round(delivery_commission_expense::numeric, 2) AS delivery_commission_expense,
|
||||
round(card_fee_expense::numeric, 2) AS card_fee_expense,
|
||||
round(repair_clean_expense::numeric, 2) AS repair_clean_expense,
|
||||
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
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab8: 盈亏平衡分析 ============
|
||||
|
||||
// 盈亏平衡分析
|
||||
router.get('/break-even', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'safety_margin_pct'
|
||||
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['safety_margin_pct', 'break_even_sales', 'sales_gap', 'food_cost_rate_pct', 'expense_rate_pct', 'received', 'actual_store_contribution', 'operating_expense_rate_pct']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'safety_margin_pct'
|
||||
|
||||
const result = await query(`
|
||||
WITH base AS (
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
received, actual_food_cost, operating_expense,
|
||||
actual_food_cost / nullif(received, 0) AS food_cost_ratio,
|
||||
operating_expense / nullif(received, 0) AS expense_ratio,
|
||||
1 - actual_food_cost / nullif(received, 0) AS contribution_margin_ratio,
|
||||
CASE
|
||||
WHEN 1 - actual_food_cost / nullif(received, 0) <= 0 THEN NULL
|
||||
ELSE operating_expense / nullif(1 - actual_food_cost / nullif(received, 0), 0)
|
||||
END AS break_even_sales
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
)
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round(actual_food_cost::numeric, 2) AS actual_food_cost,
|
||||
round(operating_expense::numeric, 2) AS operating_expense,
|
||||
round((food_cost_ratio * 100)::numeric, 2) AS food_cost_rate_pct,
|
||||
round((expense_ratio * 100)::numeric, 2) AS expense_rate_pct,
|
||||
round(break_even_sales::numeric, 2) AS break_even_sales,
|
||||
round((received - break_even_sales)::numeric, 2) AS sales_gap,
|
||||
round(((received - break_even_sales) / nullif(break_even_sales, 0) * 100)::numeric, 2) AS safety_margin_pct,
|
||||
CASE
|
||||
WHEN contribution_margin_ratio <= 0 THEN '无法盈利'
|
||||
WHEN received < break_even_sales THEN '未达盈亏平衡'
|
||||
WHEN (received - break_even_sales) / nullif(break_even_sales, 0) * 100 < 15 THEN '接近盈亏平衡'
|
||||
WHEN (received - break_even_sales) / nullif(break_even_sales, 0) * 100 < 30 THEN '安全边际较低'
|
||||
ELSE '安全边际充足'
|
||||
END AS safety_status
|
||||
FROM base
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab9: 亏损门店诊断 ============
|
||||
|
||||
// 亏损门店诊断 - 自动分析亏损原因并给出建议
|
||||
router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'actual_store_contribution'
|
||||
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND actual_store_contribution <= 0 AND received > 0`
|
||||
if (filter === 'P0') {
|
||||
where += ` AND actual_store_contribution_rate_pct < -20`
|
||||
} else if (filter === 'P1') {
|
||||
where += ` AND actual_store_contribution_rate_pct >= -20 AND actual_store_contribution_rate_pct < -5`
|
||||
} else if (filter === 'P2') {
|
||||
where += ` AND actual_store_contribution_rate_pct >= -5 AND actual_store_contribution_rate_pct <= 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
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'
|
||||
|
||||
const result = await query(`
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round(actual_food_cost::numeric, 2) AS actual_food_cost,
|
||||
round(operating_expense::numeric, 2) AS operating_expense,
|
||||
round(actual_store_contribution::numeric, 2) AS actual_store_contribution,
|
||||
round(actual_store_contribution_rate_pct::numeric, 2) AS actual_store_contribution_rate_pct,
|
||||
round(wage_expense::numeric, 2) AS wage_expense,
|
||||
round(wage_rate_pct::numeric, 2) AS wage_rate_pct,
|
||||
round(rent_expense::numeric, 2) AS rent_expense,
|
||||
round(rent_rate_pct::numeric, 2) AS rent_rate_pct,
|
||||
round(utility_expense::numeric, 2) AS utility_expense,
|
||||
round(utility_rate_pct::numeric, 2) AS utility_rate_pct,
|
||||
round(dorm_expense::numeric, 2) AS dorm_expense,
|
||||
round(delivery_commission_expense::numeric, 2) AS delivery_commission_expense,
|
||||
round(delivery_received::numeric, 2) AS delivery_received,
|
||||
round(delivery_sales_share_pct::numeric, 2) AS delivery_sales_share_pct,
|
||||
round(area_sqm::numeric, 2) AS area_sqm,
|
||||
round(received_per_sqm::numeric, 2) AS received_per_sqm,
|
||||
round(bill_count::numeric, 0) AS bill_count,
|
||||
lease_expiry_date,
|
||||
round(theoretical_cost::numeric, 2) AS theoretical_cost,
|
||||
round(theoretical_store_contribution::numeric, 2) AS theoretical_store_contribution,
|
||||
round(theoretical_store_contribution_rate_pct::numeric, 2) AS theoretical_store_contribution_rate_pct,
|
||||
CASE
|
||||
WHEN received < 10000 THEN '收入极低'
|
||||
WHEN received < 50000 THEN '收入偏低'
|
||||
ELSE '收入正常'
|
||||
END AS revenue_status,
|
||||
CASE
|
||||
WHEN actual_food_cost / nullif(received, 0) * 100 > 50 THEN '食材成本率过高'
|
||||
WHEN actual_food_cost / nullif(received, 0) * 100 > 40 THEN '食材成本率偏高'
|
||||
ELSE '食材成本率正常'
|
||||
END AS food_cost_status,
|
||||
CASE
|
||||
WHEN wage_rate_pct > 50 THEN '人工成本率严重过高'
|
||||
WHEN wage_rate_pct > 35 THEN '人工成本率过高'
|
||||
WHEN wage_rate_pct > 25 THEN '人工成本率偏高'
|
||||
ELSE '人工成本率正常'
|
||||
END AS wage_status,
|
||||
CASE
|
||||
WHEN rent_rate_pct > 25 THEN '租金占比严重过高'
|
||||
WHEN rent_rate_pct > 20 THEN '租金占比过高'
|
||||
WHEN rent_rate_pct > 15 THEN '租金占比偏高'
|
||||
ELSE '租金占比正常'
|
||||
END AS rent_status,
|
||||
CASE
|
||||
WHEN utility_rate_pct > 15 THEN '水电燃气率过高'
|
||||
WHEN utility_rate_pct > 10 THEN '水电燃气率偏高'
|
||||
ELSE '水电燃气率正常'
|
||||
END AS utility_status,
|
||||
CASE
|
||||
WHEN delivery_sales_share_pct > 40 THEN '外卖依赖度过高'
|
||||
WHEN delivery_sales_share_pct > 25 THEN '外卖占比较高'
|
||||
ELSE '外卖占比正常'
|
||||
END AS delivery_status,
|
||||
CASE
|
||||
WHEN received_per_sqm < 500 THEN '坪效极低'
|
||||
WHEN received_per_sqm < 1000 THEN '坪效偏低'
|
||||
ELSE '坪效正常'
|
||||
END AS efficiency_status,
|
||||
CASE
|
||||
WHEN lease_expiry_date IS NOT NULL AND lease_expiry_date <= DATE '2026-12-31' THEN '租约即将到期'
|
||||
ELSE '租约正常'
|
||||
END AS lease_status,
|
||||
CASE
|
||||
WHEN theoretical_store_contribution <= 0 THEN '理论即亏损'
|
||||
WHEN theoretical_store_contribution > 0 AND actual_store_contribution <= 0 THEN '实际超耗导致亏损'
|
||||
ELSE '费用过高导致亏损'
|
||||
END AS loss_type
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
|
||||
// 为每个亏损门店生成诊断原因和建议
|
||||
const rows = result.rows.map((r: any) => {
|
||||
const reasons: string[] = []
|
||||
const suggestions: string[] = []
|
||||
const actions: string[] = []
|
||||
|
||||
// 收入问题
|
||||
if (r.revenue_status === '收入极低') {
|
||||
reasons.push('门店收入极低,可能处于停业或半停业状态')
|
||||
suggestions.push('核查门店是否正常营业,评估是否值得继续经营')
|
||||
actions.push('核查营业状态')
|
||||
} else if (r.revenue_status === '收入偏低') {
|
||||
reasons.push('门店收入偏低,无法覆盖固定费用')
|
||||
suggestions.push('提升客流和客单价,开展营销活动拉动销售')
|
||||
actions.push('提升营业额')
|
||||
}
|
||||
|
||||
// 食材成本问题
|
||||
if (r.food_cost_status === '食材成本率过高') {
|
||||
reasons.push(`食材成本率高达${Math.round(r.actual_food_cost / r.received * 100)}%,远超行业标杆`)
|
||||
suggestions.push('核查BOM配方准确性、物料损耗和采购成本,优化高成本菜品')
|
||||
actions.push('优化食材成本')
|
||||
} else if (r.food_cost_status === '食材成本率偏高') {
|
||||
reasons.push(`食材成本率${Math.round(r.actual_food_cost / r.received * 100)}%偏高`)
|
||||
suggestions.push('关注物料损耗和超耗菜品,优化配方和份量')
|
||||
actions.push('控制食材成本')
|
||||
}
|
||||
|
||||
// 人工成本问题
|
||||
if (r.wage_status === '人工成本率严重过高') {
|
||||
reasons.push(`人工成本率${r.wage_rate_pct}%严重过高`)
|
||||
suggestions.push('大幅优化排班、减少冗余人员,考虑合并岗位或引入兼职')
|
||||
actions.push('优化人工配置')
|
||||
} else if (r.wage_status === '人工成本率过高') {
|
||||
reasons.push(`人工成本率${r.wage_rate_pct}%过高`)
|
||||
suggestions.push('优化排班效率,评估人员配置是否合理')
|
||||
actions.push('优化排班')
|
||||
} else if (r.wage_status === '人工成本率偏高') {
|
||||
reasons.push(`人工成本率${r.wage_rate_pct}%偏高`)
|
||||
suggestions.push('关注人效指标,适度优化排班')
|
||||
actions.push('关注人效')
|
||||
}
|
||||
|
||||
// 租金问题
|
||||
if (r.rent_status === '租金占比严重过高') {
|
||||
reasons.push(`租金占比${r.rent_rate_pct}%严重过高`)
|
||||
suggestions.push('与房东谈判降租,或评估迁址/关停方案')
|
||||
actions.push('谈判降租或迁址')
|
||||
} else if (r.rent_status === '租金占比过高') {
|
||||
reasons.push(`租金占比${r.rent_rate_pct}%过高`)
|
||||
suggestions.push('关注续租条件,争取降低租金')
|
||||
actions.push('关注租金')
|
||||
}
|
||||
|
||||
// 水电燃气问题
|
||||
if (r.utility_status === '水电燃气率过高') {
|
||||
reasons.push(`水电燃气率${r.utility_rate_pct}%过高`)
|
||||
suggestions.push('排查是否有漏水漏气,优化能源使用')
|
||||
actions.push('排查能源浪费')
|
||||
}
|
||||
|
||||
// 外卖依赖问题
|
||||
if (r.delivery_status === '外卖依赖度过高') {
|
||||
reasons.push(`外卖占比${r.delivery_sales_share_pct}%过高,佣金侵蚀利润`)
|
||||
suggestions.push('提升堂食客流,优化外卖平台策略,减少佣金支出')
|
||||
actions.push('降低外卖依赖')
|
||||
} else if (r.delivery_status === '外卖占比较高') {
|
||||
reasons.push(`外卖占比${r.delivery_sales_share_pct}%较高`)
|
||||
suggestions.push('关注外卖佣金成本,提升堂食比例')
|
||||
actions.push('优化外卖结构')
|
||||
}
|
||||
|
||||
// 坪效问题
|
||||
if (r.efficiency_status === '坪效极低') {
|
||||
reasons.push(`坪效仅${r.received_per_sqm}元/㎡,面积利用率极低`)
|
||||
suggestions.push('考虑缩减面积、改造布局或迁址至更小店面')
|
||||
actions.push('缩减面积或迁址')
|
||||
} else if (r.efficiency_status === '坪效偏低') {
|
||||
reasons.push(`坪效${r.received_per_sqm}元/㎡偏低`)
|
||||
suggestions.push('优化空间利用,提升单位面积产出')
|
||||
actions.push('提升坪效')
|
||||
}
|
||||
|
||||
// 租约到期问题
|
||||
if (r.lease_status === '租约即将到期') {
|
||||
reasons.push(`租约将于${r.lease_expiry_date}到期`)
|
||||
suggestions.push('立即启动续租谈判或关停评估,把握决策窗口')
|
||||
actions.push('启动续租评估')
|
||||
}
|
||||
|
||||
// 亏损类型综合判断
|
||||
if (r.loss_type === '理论即亏损') {
|
||||
reasons.unshift('按理论成本计算已亏损,定价或成本结构存在根本问题')
|
||||
suggestions.unshift('重新评估菜品定价策略和成本结构,必要时调整菜单价格')
|
||||
actions.unshift('调整定价策略')
|
||||
} else if (r.loss_type === '实际超耗导致亏损') {
|
||||
reasons.unshift('理论贡献为正但实际亏损,超耗是主要亏损原因')
|
||||
suggestions.unshift('重点排查物料超耗、损耗和浪费,参考菜品成本分析模块')
|
||||
actions.unshift('治理物料超耗')
|
||||
} else if (r.loss_type === '费用过高导致亏损') {
|
||||
reasons.unshift('食材成本正常但费用过高导致亏损')
|
||||
suggestions.unshift('重点削减人工、租金等费用支出')
|
||||
actions.unshift('削减费用')
|
||||
}
|
||||
|
||||
// 优先级判断
|
||||
let priority = 'P3'
|
||||
if (r.actual_store_contribution_rate_pct < -20) priority = 'P0'
|
||||
else if (r.actual_store_contribution_rate_pct < -5) priority = 'P1'
|
||||
else if (r.actual_store_contribution_rate_pct < 0) priority = 'P2'
|
||||
|
||||
return {
|
||||
...r,
|
||||
diagnosis_reasons: reasons.join(';'),
|
||||
diagnosis_suggestions: suggestions.join(';'),
|
||||
suggested_actions: actions.join('、'),
|
||||
priority
|
||||
}
|
||||
})
|
||||
|
||||
sendSuccess(res, rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ Tab10: 门店关停/续租/改造评估 ============
|
||||
|
||||
// 门店关停/续租/改造评估
|
||||
router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'actual_store_contribution_rate_pct'
|
||||
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['actual_store_contribution_rate_pct', 'received', 'actual_store_contribution', 'received_per_sqm', 'wage_rate_pct', 'rent_rate_pct', 'operating_expense_rate_pct']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'actual_store_contribution_rate_pct'
|
||||
|
||||
const result = await query(`
|
||||
SELECT sales_store_code, sales_store_name,
|
||||
round(received::numeric, 2) AS received,
|
||||
round(actual_food_cost::numeric, 2) AS actual_food_cost,
|
||||
round(operating_expense::numeric, 2) AS operating_expense,
|
||||
round(actual_store_contribution::numeric, 2) AS actual_store_contribution,
|
||||
round(actual_store_contribution_rate_pct::numeric, 2) AS actual_store_contribution_rate_pct,
|
||||
round(wage_rate_pct::numeric, 2) AS wage_rate_pct,
|
||||
round(rent_rate_pct::numeric, 2) AS rent_rate_pct,
|
||||
round(received_per_sqm::numeric, 2) AS received_per_sqm,
|
||||
round(area_sqm::numeric, 2) AS area_sqm,
|
||||
lease_expiry_date,
|
||||
round(theoretical_store_contribution::numeric, 2) AS theoretical_store_contribution,
|
||||
CASE
|
||||
WHEN received = 0 OR received < 5000 THEN '关停评估'
|
||||
WHEN actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= DATE '2026-12-31' THEN '关停评估'
|
||||
WHEN actual_store_contribution_rate_pct < -10 AND received_per_sqm < 1000 THEN '关停评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 AND lease_expiry_date <= DATE '2026-12-31' THEN '关停或迁址评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 AND received_per_sqm < 1000 AND area_sqm > 400 THEN '改造评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 AND wage_rate_pct > 35 THEN '改造评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 THEN '关注观察'
|
||||
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '续租评估'
|
||||
WHEN actual_store_contribution_rate_pct > 0 AND actual_store_contribution_rate_pct < 10 THEN '关注观察'
|
||||
ELSE '正常经营'
|
||||
END AS evaluation_type,
|
||||
CASE
|
||||
WHEN received = 0 OR received < 5000 THEN 'P0'
|
||||
WHEN actual_store_contribution_rate_pct < -20 THEN 'P0'
|
||||
WHEN actual_store_contribution_rate_pct < -10 THEN 'P1'
|
||||
WHEN actual_store_contribution_rate_pct < 0 THEN 'P2'
|
||||
WHEN actual_store_contribution_rate_pct < 10 THEN 'P3'
|
||||
ELSE 'P4'
|
||||
END AS priority
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN received = 0 OR received < 5000 THEN 0
|
||||
WHEN actual_store_contribution_rate_pct < -20 THEN 1
|
||||
WHEN actual_store_contribution_rate_pct < -10 THEN 2
|
||||
WHEN actual_store_contribution_rate_pct < 0 THEN 3
|
||||
WHEN actual_store_contribution_rate_pct < 10 THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
${sortCol} ${order} NULLS LAST
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
|
||||
// 格式化日期
|
||||
const fmtDate = (d: any) => d ? new Date(d).toLocaleDateString('zh-CN') : '-'
|
||||
|
||||
// 为每个门店生成评估详情和建议
|
||||
const rows = result.rows.map((r: any) => {
|
||||
let detail = ''
|
||||
let suggestion = ''
|
||||
|
||||
switch (r.evaluation_type) {
|
||||
case '关停评估':
|
||||
detail = r.received < 5000
|
||||
? `月收入仅${r.received}元,基本无营业活动,继续经营只会增加亏损`
|
||||
: `贡献利润率${r.actual_store_contribution_rate_pct}%,严重亏损且租约即将到期,关停可止损`
|
||||
suggestion = r.received < 5000
|
||||
? '建议立即关停,减少固定费用支出;若为新店筹备期则明确开业时间表'
|
||||
: '建议到期不再续租,提前制定关停计划(人员分流、设备调拨、会员退款)'
|
||||
break
|
||||
case '关停或迁址评估':
|
||||
detail = `贡献利润率${r.actual_store_contribution_rate_pct}%,亏损运营且租约${fmtDate(r.lease_expiry_date)}到期`
|
||||
suggestion = '评估周边市场潜力:若商圈有潜力则谈判降租续营,否则到期关停并寻找更低租金位置迁址'
|
||||
break
|
||||
case '改造评估':
|
||||
if (r.received_per_sqm < 1000 && r.area_sqm > 400) {
|
||||
detail = `坪效${r.received_per_sqm}元/㎡偏低,面积${r.area_sqm}㎡过大,空间利用率不足`
|
||||
suggestion = '考虑缩减营业面积(退租部分区域),或改造空间布局增加外卖/零售功能'
|
||||
} else {
|
||||
detail = `人工成本率${r.wage_rate_pct}%偏高且持续亏损,运营效率需提升`
|
||||
suggestion = '优化人员配置和排班制度,引入数字化点餐减少人工依赖,必要时进行门店改造'
|
||||
}
|
||||
break
|
||||
case '续租评估':
|
||||
detail = `租约${fmtDate(r.lease_expiry_date)}即将到期,当前贡献利润率${r.actual_store_contribution_rate_pct}%`
|
||||
suggestion = '若盈利则尽快启动续租谈判锁定租金,若微利则谈判降租条件,设定续租租金上限'
|
||||
break
|
||||
case '关注观察':
|
||||
detail = r.actual_store_contribution_rate_pct < 0
|
||||
? `贡献利润率${r.actual_store_contribution_rate_pct}%,持续亏损需关注`
|
||||
: `贡献利润率仅${r.actual_store_contribution_rate_pct}%,盈利能力薄弱`
|
||||
suggestion = r.actual_store_contribution_rate_pct < 0
|
||||
? '分析亏损原因,制定改善计划提升营业额或降低费用,避免亏损扩大'
|
||||
: '持续监控经营状况,制定改善计划提升营业额或降低费用,避免滑入亏损'
|
||||
break
|
||||
default:
|
||||
detail = '经营状况正常'
|
||||
suggestion = '保持现有运营水平,持续优化费用结构'
|
||||
}
|
||||
|
||||
return { ...r, lease_expiry_date: r.lease_expiry_date ? new Date(r.lease_expiry_date).toISOString().split('T')[0] : null, evaluation_detail: detail, evaluation_suggestion: suggestion }
|
||||
})
|
||||
|
||||
// 全量统计各评估类型数量
|
||||
const statsResult = await query(`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE received = 0 OR received < 5000 OR (actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= DATE '2026-12-31') OR (actual_store_contribution_rate_pct < -10 AND received_per_sqm < 1000)) AS "关停评估",
|
||||
count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND received_per_sqm < 1000 AND area_sqm > 400) + count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND wage_rate_pct > 35) AS "改造评估",
|
||||
count(*) FILTER (WHERE lease_expiry_date <= DATE '2026-06-30' AND actual_store_contribution_rate_pct >= 0) AS "续租评估",
|
||||
count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND lease_expiry_date > DATE '2026-12-31' AND NOT (received_per_sqm < 1000 AND area_sqm > 400) AND wage_rate_pct <= 35) + count(*) FILTER (WHERE actual_store_contribution_rate_pct > 0 AND actual_store_contribution_rate_pct < 10) AS "关注观察"
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
`)
|
||||
const evalStats = statsResult.rows[0]
|
||||
|
||||
sendSuccess(res, rows, { page, pageSize, total, evalStats })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ 数据质量 ============
|
||||
|
||||
// 费用数据质量
|
||||
router.get('/data-quality', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT * FROM analytics.v_operating_expense_data_quality
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user