风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距

This commit is contained in:
freedakgmail
2026-08-01 21:28:15 +08:00
parent ac8e39e2f9
commit d724a565f5
60 changed files with 3154 additions and 2561 deletions
+3 -2
View File
@@ -6,17 +6,18 @@ interface CollapsibleSectionProps {
subtitle?: string
defaultOpen?: boolean
headerRight?: ReactNode
onToggle?: (open: boolean) => void
children: ReactNode
}
export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, children }: CollapsibleSectionProps) {
export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, onToggle, children }: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-lg border bg-card">
<button
className="flex w-full items-center justify-between p-4 text-left"
onClick={() => setOpen(!open)}
onClick={() => { const next = !open; setOpen(next); onToggle?.(next) }}
>
<div className="flex items-center gap-2">
<span className={cn('text-sm font-bold transition-transform', open ? 'rotate-90' : 'rotate-0')}></span>
+4 -2
View File
@@ -5,11 +5,12 @@ interface DataTableProps {
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
data: any[]
onRowClick?: (row: any) => void
onRowDoubleClick?: (row: any) => void
className?: string
rowClassName?: (row: any) => string
}
export function DataTable({ columns, data, onRowClick, className, rowClassName }: DataTableProps) {
export function DataTable({ columns, data, onRowClick, onRowDoubleClick, className, rowClassName }: DataTableProps) {
return (
<div className={cn('overflow-x-auto rounded-lg border', className)}>
<table className="w-full text-sm">
@@ -42,9 +43,10 @@ export function DataTable({ columns, data, onRowClick, className, rowClassName }
<tr
key={i}
onClick={() => onRowClick?.(row)}
onDoubleClick={() => onRowDoubleClick?.(row)}
className={cn(
'border-t hover:bg-muted/30',
onRowClick && 'cursor-pointer',
(onRowClick || onRowDoubleClick) && 'cursor-pointer',
rowClassName?.(row)
)}
>
+110 -19
View File
@@ -3,12 +3,13 @@ import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import type { ReactNode } from 'react'
const PAGE_SIZE = 20
const DEFAULT_PAGE_SIZE = 20
interface FilterableTableProps {
data: any[]
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
onRowClick?: (row: any) => void
onRowDoubleClick?: (row: any) => void
filterKey?: string
filterLabel?: string
sortOptions: { key: string; label: string }[]
@@ -17,67 +18,157 @@ interface FilterableTableProps {
statusFilterKey?: string
statusFilterLabel?: string
statusOptions?: { value: string; label: string }[]
statusFilterValue?: string
onStatusFilterChange?: (value: string) => void
pageSize?: number
serverSide?: boolean
total?: number
page?: number
onPageChange?: (page: number) => void
sort?: string
order?: 'asc' | 'desc'
onSortChange?: (sort: string) => void
onOrderChange?: (order: 'asc' | 'desc') => void
filter?: string
onFilterChange?: (filter: string) => void
filterButtons?: { key: string; label: string }[]
filterOptions?: string[]
searchKeys?: string[]
searchPlaceholder?: string
serverSearchValue?: string
onServerSearchChange?: (value: string) => void
}
export function FilterableTable({
data, columns, onRowClick,
data, columns, onRowClick, onRowDoubleClick,
filterKey, filterLabel,
sortOptions, defaultSort, defaultOrder = 'desc',
statusFilterKey, statusFilterLabel, statusOptions,
statusFilterKey, statusFilterLabel, statusOptions, statusFilterValue, onStatusFilterChange,
pageSize = DEFAULT_PAGE_SIZE,
serverSide = false,
total: serverTotal,
page: serverPage,
onPageChange,
sort: serverSort,
order: serverOrder,
onSortChange,
onOrderChange,
filter: serverFilter,
onFilterChange,
filterButtons,
filterOptions,
searchKeys,
searchPlaceholder,
serverSearchValue,
onServerSearchChange,
}: FilterableTableProps) {
const [page, setPage] = useState(1)
const [filter, setFilter] = useState('')
const [sort, setSort] = useState(defaultSort || sortOptions[0]?.key || '')
const [order, setOrder] = useState<'asc' | 'desc'>(defaultOrder)
const [localPage, setLocalPage] = useState(1)
const [localFilter, setLocalFilter] = useState('')
const [localSort, setLocalSort] = useState(defaultSort || sortOptions[0]?.key || '')
const [localOrder, setLocalOrder] = useState<'asc' | 'desc'>(defaultOrder)
const [statusFilter, setStatusFilter] = useState('')
const currentStatusFilter = serverSide ? (statusFilterValue ?? '') : statusFilter
const [searchText, setSearchText] = useState('')
const page = serverSide ? (serverPage ?? 1) : localPage
const sort = serverSide ? (serverSort ?? localSort) : localSort
const order = serverSide ? (serverOrder ?? localOrder) : localOrder
const filter = serverSide ? (serverFilter ?? '') : localFilter
const setPage = (p: number) => {
if (serverSide) onPageChange?.(p)
else setLocalPage(p)
}
const setSortVal = (s: string) => {
if (serverSide) onSortChange?.(s)
else setLocalSort(s)
setPage(1)
}
const setOrderVal = (o: 'asc' | 'desc') => {
if (serverSide) onOrderChange?.(o)
else setLocalOrder(o)
setPage(1)
}
const setFilterVal = (f: string) => {
if (serverSide) onFilterChange?.(f)
else setLocalFilter(f)
setPage(1)
}
const filterValues = useMemo(() => {
if (filterOptions) return filterOptions
if (!filterKey) return []
return [...new Set(data.map((r: any) => r[filterKey]).filter(Boolean))].sort() as string[]
}, [data, filterKey])
}, [data, filterKey, filterOptions])
const filtered = useMemo(() => {
if (serverSide) return data
let r = data
if (filter && filterKey) r = r.filter((row: any) => row[filterKey] === filter)
if (statusFilter && statusFilterKey) r = r.filter((row: any) => String(row[statusFilterKey]) === statusFilter)
if (currentStatusFilter && statusFilterKey) r = r.filter((row: any) => String(row[statusFilterKey]) === currentStatusFilter)
if (searchText && searchKeys) r = r.filter((row: any) => searchKeys.some(k => String(row[k] ?? '').toLowerCase().includes(searchText.toLowerCase())))
return [...r].sort((a: any, b: any) => {
const av = parseFloat(a[sort]) || (typeof a[sort] === 'string' ? 0 : a[sort] || 0)
const bv = parseFloat(b[sort]) || (typeof b[sort] === 'string' ? 0 : b[sort] || 0)
return order === 'desc' ? bv - av : av - bv
})
}, [data, filter, filterKey, sort, order, statusFilter, statusFilterKey])
}, [data, filter, filterKey, sort, order, currentStatusFilter, statusFilterKey, serverSide, searchText, searchKeys])
const total = filtered.length
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const total = serverSide ? (serverTotal ?? 0) : filtered.length
const paged = serverSide ? data : filtered.slice((page - 1) * pageSize, page * pageSize)
return (
<>
<div className="mb-3 flex flex-wrap gap-2">
{filterKey && (
<select value={filter} onChange={(e) => { setFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
{searchKeys && !serverSide && (
<input
type="text"
placeholder={searchPlaceholder || '搜索...'}
value={searchText}
onChange={(e) => { setSearchText(e.target.value); setPage(1) }}
className="rounded border px-3 py-1 text-sm"
/>
)}
{serverSide && onServerSearchChange && (
<input
type="text"
placeholder={searchPlaceholder || '搜索...'}
value={serverSearchValue ?? ''}
onChange={(e) => { onServerSearchChange(e.target.value); setPage(1) }}
className="rounded border px-3 py-1 text-sm"
/>
)}
{filterButtons ? (
filterButtons.map(f => (
<button key={f.key} onClick={() => setFilterVal(f.key)} className={`rounded px-3 py-1 text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
{f.label}
</button>
))
) : filterKey && (
<select value={filter} onChange={(e) => setFilterVal(e.target.value)} className="rounded border px-2 py-1 text-sm">
<option value="">{filterLabel || '全部'}</option>
{filterValues.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
{statusFilterKey && statusOptions && (
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
<select value={currentStatusFilter} onChange={(e) => { if (serverSide && onStatusFilterChange) { onStatusFilterChange(e.target.value) } else { setStatusFilter(e.target.value) }; setPage(1) }} className="rounded border px-2 py-1 text-sm">
<option value="">{statusFilterLabel || '全部状态'}</option>
{statusOptions.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
)}
<span className="mx-1 text-muted-foreground">|</span>
{sortOptions.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`rounded px-3 py-1 text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
<button key={s.key} onClick={() => setSortVal(s.key)} className={`rounded px-3 py-1 text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
{s.label}
</button>
))}
<span className="mx-1 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="rounded bg-muted px-3 py-1 text-xs">
<button onClick={() => setOrderVal(order === 'asc' ? 'desc' : 'asc')} className="rounded bg-muted px-3 py-1 text-xs">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable columns={columns} data={paged} onRowClick={onRowClick} />
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
<DataTable columns={columns} data={paged} onRowClick={onRowClick} onRowDoubleClick={onRowDoubleClick} />
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
</>
)
}
+1 -1
View File
@@ -112,7 +112,7 @@ export function Layout({ children, user, onLogout }: LayoutProps) {
<div className="flex h-14 items-center border-b px-4">
<span className="text-lg font-bold"></span>
</div>
<nav className="space-y-4 overflow-y-auto p-3" style={{ maxHeight: 'calc(100vh - 3.5rem - 5rem)' }}>
<nav className="space-y-4 overflow-y-auto p-3 pb-20" style={{ maxHeight: 'calc(100vh - 3.5rem - 5rem)' }}>
{visibleGroups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-3 text-xs font-medium text-muted-foreground">{group.title}</p>
@@ -2,9 +2,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber, priorityColor } from '@/lib/utils'
import { useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend } from 'recharts'
@@ -30,7 +29,7 @@ const STATUS_LABELS: Record<string, string> = {
'cancelled': '已取消',
}
export function AdjustmentTab() {
export function AdjustmentTab({ month }: { month: string }) {
const queryClient = useQueryClient()
const [subTab, setSubTab] = useState<'diagnosis' | 'adjustment' | 'verify'>('diagnosis')
const [diagPage, setDiagPage] = useState(1)
@@ -38,15 +37,15 @@ export function AdjustmentTab() {
const [diagPriority, setDiagPriority] = useState('')
const [adjStatus, setAdjStatus] = useState('')
const [diagSort, setDiagSort] = useState('priority')
const [diagOrder, setDiagOrder] = useState('asc')
const [diagOrder, setDiagOrder] = useState<'asc' | 'desc'>('asc')
const [adjSort, setAdjSort] = useState('effective_date')
const [adjOrder, setAdjOrder] = useState('desc')
const [adjOrder, setAdjOrder] = useState<'asc' | 'desc'>('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, 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: diag, isLoading: ld } = useQuery({ queryKey: ['ca/diagnosis', month, diagPage, diagPriority, diagSort, diagOrder], queryFn: () => api.get(`/cost-analysis/diagnosis`, { params: { month, page: diagPage, page_size: PAGE_SIZE, sort: diagSort, order: diagOrder, ...(diagPriority ? { priority: diagPriority } : {}) } }) })
const { data: adj, isLoading: la } = useQuery({ queryKey: ['ca/adjustment', month, adjPage, adjStatus, adjSort, adjOrder], queryFn: () => api.get(`/cost-analysis/adjustment`, { params: { month, 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({
@@ -124,19 +123,6 @@ 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">
@@ -146,30 +132,42 @@ export function AdjustmentTab() {
</div>
{ld ? <LoadingSpinner text="加载诊断..." /> : (
<>
<Pagination page={diagPage} pageSize={PAGE_SIZE} total={diagMeta.total} onPageChange={setDiagPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_l1', label: '品类' },
{ key: 'diagnosis_type', label: '诊断类型' },
{ key: 'diagnosis_detail', label: '诊断详情' },
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'actual_margin_pct', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin_pct) },
{ key: 'cost_variance_amount', label: '成本差异', align: 'right', render: (r) => formatCurrency(r.cost_variance_amount) },
{ key: 'suggested_action', label: '建议动作', render: (r) => ACTION_LABELS[r.suggested_action] || r.suggested_action },
{ key: 'priority', label: '优先级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium border ${priorityColor(r.priority)}`}>{r.priority}</span>
)},
{ key: 'action', label: '操作', render: (r) => (
<button onClick={() => handleCreateAdjustment(r)} className="text-xs text-blue-600 hover:underline"></button>
)},
]}
data={diagData}
/>
</div>
</>
<FilterableTable
data={diagData}
serverSide
page={diagPage}
onPageChange={setDiagPage}
sort={diagSort}
onSortChange={setDiagSort}
order={diagOrder}
onOrderChange={setDiagOrder}
pageSize={PAGE_SIZE}
total={diagMeta.total}
sortOptions={[
{ key: 'priority', label: '优先级' },
{ key: 'cost_variance_amount', label: '成本差异' },
{ key: 'sales_amount', label: '销售额' },
{ key: 'actual_margin_pct', label: '实际毛利率' },
]}
defaultSort="priority"
defaultOrder="asc"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_l1', label: '品类' },
{ key: 'diagnosis_type', label: '诊断类型' },
{ key: 'diagnosis_detail', label: '诊断详情' },
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'actual_margin_pct', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin_pct) },
{ key: 'cost_variance_amount', label: '成本差异', align: 'right', render: (r) => formatCurrency(r.cost_variance_amount) },
{ key: 'suggested_action', label: '建议动作', render: (r) => ACTION_LABELS[r.suggested_action] || r.suggested_action },
{ key: 'priority', label: '优先级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium border ${priorityColor(r.priority)}`}>{r.priority}</span>
)},
{ key: 'action', label: '操作', render: (r) => (
<button onClick={() => handleCreateAdjustment(r)} className="text-xs text-blue-600 hover:underline"></button>
)},
]}
/>
)}
{showForm && (
@@ -214,46 +212,44 @@ 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="加载调整记录..." /> : (
<>
<Pagination page={adjPage} pageSize={PAGE_SIZE} total={adjMeta.total} onPageChange={setAdjPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'adjustment_type', label: '调整类型', render: (r) => ACTION_LABELS[r.adjustment_type] || r.adjustment_type },
{ key: 'effective_date', label: '执行日期' },
{ key: 'before_theoretical_margin', label: '调整前毛利率', align: 'right', render: (r) => formatPercent(r.before_theoretical_margin) },
{ key: 'after_target_margin', label: '目标毛利率', align: 'right', render: (r) => formatPercent(r.after_target_margin) },
{ key: 'decided_by', label: '决策人' },
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{STATUS_LABELS[r.status] || r.status}</span>
)},
{ key: 'actions', label: '操作', render: (r) => (
<div className="flex gap-2">
{r.status === 'planned' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'executing' })} className="text-xs text-blue-600 hover:underline"></button>}
{r.status === 'executing' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'completed' })} className="text-xs text-green-600 hover:underline"></button>}
<button onClick={() => { setVerifyId(r.id); setSubTab('verify') }} className="text-xs text-blue-600 hover:underline"></button>
</div>
)},
]}
data={adjData}
/>
</div>
</>
<FilterableTable
data={adjData}
serverSide
page={adjPage}
onPageChange={setAdjPage}
sort={adjSort}
onSortChange={setAdjSort}
order={adjOrder}
onOrderChange={setAdjOrder}
pageSize={PAGE_SIZE}
total={adjMeta.total}
sortOptions={[
{ key: 'effective_date', label: '执行日期' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'adjustment_type', label: '调整类型' },
]}
defaultSort="effective_date"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'adjustment_type', label: '调整类型', render: (r) => ACTION_LABELS[r.adjustment_type] || r.adjustment_type },
{ key: 'effective_date', label: '执行日期' },
{ key: 'before_theoretical_margin', label: '调整前毛利率', align: 'right', render: (r) => formatPercent(r.before_theoretical_margin) },
{ key: 'after_target_margin', label: '目标毛利率', align: 'right', render: (r) => formatPercent(r.after_target_margin) },
{ key: 'decided_by', label: '决策人' },
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{STATUS_LABELS[r.status] || r.status}</span>
)},
{ key: 'actions', label: '操作', render: (r) => (
<div className="flex gap-2">
{r.status === 'planned' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'executing' })} className="text-xs text-blue-600 hover:underline"></button>}
{r.status === 'executing' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'completed' })} className="text-xs text-green-600 hover:underline"></button>}
<button onClick={() => { setVerifyId(r.id); setSubTab('verify') }} className="text-xs text-blue-600 hover:underline"></button>
</div>
)},
]}
/>
)}
</>
)}
@@ -291,7 +287,14 @@ export function AdjustmentTab() {
</CollapsibleSection>
<CollapsibleSection title="调整详情" defaultOpen={false}>
<DataTable
<FilterableTable
data={[verifyData.adjustment]}
sortOptions={[
{ key: 'dish_name', label: '菜品' },
{ key: 'effective_date', label: '执行日期' },
]}
defaultSort="dish_name"
defaultOrder="asc"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'adjustment_type', label: '调整类型', render: (r) => ACTION_LABELS[r.adjustment_type] || r.adjustment_type },
@@ -301,7 +304,6 @@ export function AdjustmentTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{STATUS_LABELS[r.status] || r.status}</span>
)},
]}
data={[verifyData.adjustment]}
/>
</CollapsibleSection>
</>
+86 -73
View File
@@ -2,32 +2,31 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
const PAGE_SIZE = 15
export function BomTab() {
export function BomTab({ month }: { month: string }) {
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 [complexOrder, setComplexOrder] = useState<'asc' | 'desc'>('desc')
const [complexFilter, setComplexFilter] = useState('')
const [missSort, setMissSort] = useState('sales_amount')
const [missOrder, setMissOrder] = useState('desc')
const [missOrder, setMissOrder] = useState<'asc' | 'desc'>('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, 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, missSort, missOrder], queryFn: () => api.get(`/cost-analysis/bom-missing?page=${missingPage}&page_size=${PAGE_SIZE}&sort=${missSort}&order=${missOrder}`) })
const { data: bomOv, isLoading: l1 } = useQuery({ queryKey: ['ca/bom-overview', month], queryFn: () => api.get('/cost-analysis/bom-overview', { params: { month } }) })
const { data: complexity, isLoading: l2 } = useQuery({ queryKey: ['ca/bom-complexity', month, complexPage, complexSort, complexOrder, complexFilter], queryFn: () => api.get(`/cost-analysis/bom-complexity`, { params: { month, page: complexPage, page_size: PAGE_SIZE, sort: complexSort, order: complexOrder, filter: complexFilter } }) })
const { data: highLoss, isLoading: l3 } = useQuery({ queryKey: ['ca/bom-high-loss', month], queryFn: () => api.get('/cost-analysis/bom-high-loss', { params: { month, threshold: 20 } }) })
const { data: missing, isLoading: l4 } = useQuery({ queryKey: ['ca/bom-missing', month, missingPage, missSort, missOrder], queryFn: () => api.get(`/cost-analysis/bom-missing`, { params: { month, 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}`),
queryKey: ['ca/bom-composition', month, selectedSku],
queryFn: () => api.get(`/cost-analysis/bom-composition`, { params: { month, sku_code: selectedSku } }),
enabled: !!selectedSku,
})
@@ -85,53 +84,61 @@ export function BomTab() {
</CollapsibleSection>
<CollapsibleSection title="BOM复杂度明细" subtitle="支持筛选和排序">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={compData}
serverSide
page={complexPage}
onPageChange={setComplexPage}
sort={complexSort}
onSortChange={setComplexSort}
order={complexOrder}
onOrderChange={setComplexOrder}
filter={complexFilter}
onFilterChange={setComplexFilter}
pageSize={PAGE_SIZE}
total={compMeta.total}
filterButtons={[
{ 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>
{[
]}
sortOptions={[
{ 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
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名', render: (r) => (
<span className="text-blue-600 cursor-pointer hover:underline" onClick={() => setSelectedSku(r.sku_code)}>{r.dish_name}</span>
)},
{ key: 'category_l1', label: '品类' },
{ key: 'material_count', label: '原料数', align: 'right', render: (r) => formatNumber(r.material_count) },
{ key: 'semi_finished_count', label: '半成品数', align: 'right', render: (r) => formatNumber(r.semi_finished_count) },
{ key: 'unique_material_count', label: '独有原料', align: 'right', render: (r) => formatNumber(r.unique_material_count) },
{ key: 'high_loss_count', label: '高损耗原料', align: 'right', render: (r) => r.high_loss_count > 0 ? <span className="text-red-600">{r.high_loss_count}</span> : '0' },
{ key: 'complexity_level', label: '复杂度', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${complexityColor(r.complexity_level)}`}>{r.complexity_level}</span>
)},
]}
data={compData}
/>
</div>
]}
defaultSort="material_count"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名', render: (r) => (
<span className="text-blue-600 cursor-pointer hover:underline" onClick={() => setSelectedSku(r.sku_code)}>{r.dish_name}</span>
)},
{ key: 'category_l1', label: '品类' },
{ key: 'material_count', label: '原料数', align: 'right', render: (r) => formatNumber(r.material_count) },
{ key: 'semi_finished_count', label: '半成品数', align: 'right', render: (r) => formatNumber(r.semi_finished_count) },
{ key: 'unique_material_count', label: '独有原料', align: 'right', render: (r) => formatNumber(r.unique_material_count) },
{ key: 'high_loss_count', label: '高损耗原料', align: 'right', render: (r) => r.high_loss_count > 0 ? <span className="text-red-600">{r.high_loss_count}</span> : '0' },
{ key: 'complexity_level', label: '复杂度', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${complexityColor(r.complexity_level)}`}>{r.complexity_level}</span>
)},
]}
/>
</CollapsibleSection>
{selectedSku && (
<CollapsibleSection title={`菜品物料构成: ${selectedSku}`} subtitle="点击菜品名查看物料详情" defaultOpen={true}>
{l5 ? <LoadingSpinner text="加载物料构成..." /> : (
<DataTable
<FilterableTable
data={compDetail}
sortOptions={[
{ key: 'cost_share', label: '成本占比' },
{ key: 'waste_rate', label: '损耗率' },
{ key: 'gross_qty', label: '标准毛用量' },
]}
defaultSort="cost_share"
defaultOrder="desc"
filterKey="material_type"
filterLabel="全部类型"
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'material_type', label: '类型', align: 'center' },
@@ -141,14 +148,22 @@ export function BomTab() {
{ key: 'waste_rate', label: '损耗率', align: 'right', render: (r) => formatPercent(r.waste_rate) },
{ key: 'cost_share', label: '成本占比', align: 'right', render: (r) => formatPercent(r.cost_share) },
]}
data={compDetail}
/>
)}
</CollapsibleSection>
)}
<CollapsibleSection title="高损耗BOM预警" subtitle="损耗率 ≥ 20%的BOM记录" defaultOpen={false}>
<DataTable
<FilterableTable
data={lossData}
sortOptions={[
{ key: 'waste_rate', label: '损耗率' },
{ key: 'yield_rate', label: '出成率' },
]}
defaultSort="waste_rate"
defaultOrder="desc"
filterKey="alert_level"
filterLabel="全部级别"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'material_name', label: '物料' },
@@ -159,37 +174,35 @@ export function BomTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${alertColor(r.alert_level)}`}>{r.alert_level}</span>
)},
]}
data={lossData}
/>
</CollapsibleSection>
<CollapsibleSection title="缺BOM的SKU清单" subtitle="优先补齐高销量SKU的BOM" defaultOpen={false}>
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={missData}
serverSide
page={missingPage}
onPageChange={setMissingPage}
sort={missSort}
onSortChange={setMissSort}
order={missOrder}
onOrderChange={setMissOrder}
pageSize={PAGE_SIZE}
total={missMeta.total}
sortOptions={[
{ 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
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => r.sales_amount > 0 ? `¥${formatNumber(r.sales_amount)}` : '-' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => r.sales_quantity > 0 ? formatNumber(r.sales_quantity) : '-' },
]}
data={missData}
/>
</div>
]}
defaultSort="sales_amount"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => r.sales_amount > 0 ? `¥${formatNumber(r.sales_amount)}` : '-' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => r.sales_quantity > 0 ? formatNumber(r.sales_quantity) : '-' },
]}
/>
</CollapsibleSection>
</div>
)
@@ -7,8 +7,8 @@ import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ZAxis, Res
const CATEGORY_COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f97316', '#eab308', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#a3a3a3', '#f43f5e', '#6366f1', '#14b8a6', '#facc15', '#7c3aed', '#10b981', '#fb923c', '#64748b', '#d946ef']
export function ExploreTab() {
const { data: scatter, isLoading } = useQuery({ queryKey: ['ca/scatter'], queryFn: () => api.get('/cost-analysis/scatter') })
export function ExploreTab({ month }: { month: string }) {
const { data: scatter, isLoading } = useQuery({ queryKey: ['ca/scatter', month], queryFn: () => api.get('/cost-analysis/scatter', { params: { month } }) })
if (isLoading) return <LoadingSpinner text="加载散点图数据..." />
@@ -1,42 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
const PAGE_SIZE = 15
const PIE_COLORS = ['#22c55e', '#3b82f6', '#eab308', '#f97316', '#ef4444', '#8b5cf6', '#a3a3a3']
export function MaterialTab() {
export function MaterialTab({ month }: { month: string }) {
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],
queryFn: () => api.get(`/cost-analysis/material-variance?dish_code=${dishCode}`),
queryKey: ['ca/material-variance', month, dishCode],
queryFn: () => api.get(`/cost-analysis/material-variance`, { params: { month, dish_code: dishCode } }),
enabled: !!dishCode,
})
const { data: lossDist, isLoading: ld } = useQuery({ queryKey: ['ca/loss-distribution'], queryFn: () => api.get('/cost-analysis/loss-distribution') })
const { data: lossTop, isLoading: lt } = useQuery({ queryKey: ['ca/material-loss-top'], queryFn: () => api.get('/cost-analysis/material-loss-top?limit=50') })
const { data: typeLoss, isLoading: ltl } = useQuery({ queryKey: ['ca/material-type-loss'], queryFn: () => api.get('/cost-analysis/material-type-loss') })
const { data: lossDist, isLoading: ld } = useQuery({ queryKey: ['ca/loss-distribution', month], queryFn: () => api.get('/cost-analysis/loss-distribution', { params: { month } }) })
const { data: lossTop, isLoading: lt } = useQuery({ queryKey: ['ca/material-loss-top', month], queryFn: () => api.get('/cost-analysis/material-loss-top', { params: { month, limit: 50 } }) })
const { data: typeLoss, isLoading: ltl } = useQuery({ queryKey: ['ca/material-type-loss', month], queryFn: () => api.get('/cost-analysis/material-type-loss', { params: { month } }) })
if (ld || lt || ltl) return <LoadingSpinner text="加载原料差异数据..." />
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) => {
@@ -90,7 +79,17 @@ export function MaterialTab() {
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
<FilterableTable
data={varianceData}
sortOptions={[
{ key: 'loss_amount', label: '金额差异' },
{ key: 'loss_rate', label: '损耗率' },
{ key: 'loss_qty', label: '数量差异' },
]}
defaultSort="loss_amount"
defaultOrder="asc"
filterKey="reason"
filterLabel="全部原因"
columns={[
{ key: 'material_name', label: '原料' },
{ key: 'material_unit', label: '单位', align: 'center' },
@@ -106,7 +105,6 @@ export function MaterialTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${reasonColor(r.reason)}`}>{r.reason}</span>
)},
]}
data={varianceData}
/>
</div>
</>
@@ -125,51 +123,56 @@ export function MaterialTab() {
</PieChart>
</ResponsiveContainer>
<div className="flex-1">
<DataTable
<FilterableTable
data={distData}
sortOptions={[
{ key: 'cnt', label: '明细数' },
{ key: 'loss_band', label: '损耗区间' },
]}
defaultSort="cnt"
defaultOrder="desc"
columns={[
{ key: 'loss_band', label: '损耗区间' },
{ key: 'cnt', label: '明细数', align: 'right' },
]}
data={distData}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="超耗物料TOP榜" subtitle="支持排序">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={topData}
sortOptions={[
{ 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={[
{ key: 'material_name', label: '物料' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'total_loss_qty', label: '总损耗量', align: 'right', render: (r) => formatNumber(r.total_loss_qty) },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.avg_loss_rate)}</span> },
{ 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={sortedTopData.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)}
/>
</div>
]}
defaultSort="total_loss_qty"
defaultOrder="asc"
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'total_loss_qty', label: '总损耗量', align: 'right', render: (r) => formatNumber(r.total_loss_qty) },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.avg_loss_rate)}</span> },
{ 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) },
]}
/>
</CollapsibleSection>
<CollapsibleSection title="物料类型损耗对比" subtitle="原材料 vs 半成品" defaultOpen={false}>
<DataTable
<FilterableTable
data={typeData}
sortOptions={[
{ key: 'total_loss_amount', label: '损耗金额' },
{ key: 'avg_loss_rate', label: '平均损耗率' },
{ key: 'cnt', label: '明细数' },
]}
defaultSort="total_loss_amount"
defaultOrder="desc"
columns={[
{ key: 'material_type', label: '物料类型' },
{ key: 'cnt', label: '明细数', align: 'right' },
@@ -177,7 +180,6 @@ export function MaterialTab() {
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => formatPercent(r.avg_loss_rate) },
{ key: 'total_loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.total_loss_amount) },
]}
data={typeData}
/>
</CollapsibleSection>
</div>
@@ -2,18 +2,18 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PIE_COLORS = ['#ef4444', '#f97316', '#eab308', '#3b82f6', '#22c55e', '#a3a3a3', '#8b5cf6']
export function OverviewTab() {
const { data: overview, isLoading: l1 } = useQuery({ queryKey: ['ca/overview'], queryFn: () => api.get('/cost-analysis/overview') })
const { data: categories, isLoading: l2 } = useQuery({ queryKey: ['ca/category-comparison'], queryFn: () => api.get('/cost-analysis/category-comparison') })
const { data: deviation, isLoading: l3 } = useQuery({ queryKey: ['ca/margin-deviation'], queryFn: () => api.get('/cost-analysis/margin-deviation') })
const { data: varianceTop, isLoading: l4 } = useQuery({ queryKey: ['ca/variance-top'], queryFn: () => api.get('/cost-analysis/variance-top?limit=50') })
export function OverviewTab({ month }: { month: string }) {
const { data: overview, isLoading: l1 } = useQuery({ queryKey: ['ca/overview', month], queryFn: () => api.get('/cost-analysis/overview', { params: { month } }) })
const { data: categories, isLoading: l2 } = useQuery({ queryKey: ['ca/category-comparison', month], queryFn: () => api.get('/cost-analysis/category-comparison', { params: { month } }) })
const { data: deviation, isLoading: l3 } = useQuery({ queryKey: ['ca/margin-deviation', month], queryFn: () => api.get('/cost-analysis/margin-deviation', { params: { month } }) })
const { data: varianceTop, isLoading: l4 } = useQuery({ queryKey: ['ca/variance-top', month], queryFn: () => api.get('/cost-analysis/variance-top', { params: { month, limit: 50 } }) })
if (l1 || l2 || l3 || l4) return <LoadingSpinner text="加载成本总览..." />
@@ -66,7 +66,18 @@ export function OverviewTab() {
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
<FilterableTable
data={cats}
sortOptions={[
{ key: 'total_variance', label: '成本差异' },
{ key: 'avg_actual_margin', label: '实际毛利率' },
{ key: 'avg_theo_margin', label: '理论毛利率' },
{ key: 'total_sales', label: '销售额' },
]}
defaultSort="total_variance"
defaultOrder="desc"
filterKey="category_level1"
filterLabel="全部品类"
columns={[
{ key: 'category_level1', label: '品类' },
{ key: 'dishes', label: '菜品数', align: 'right' },
@@ -78,7 +89,6 @@ export function OverviewTab() {
return <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
]}
data={cats}
/>
</div>
</CollapsibleSection>
@@ -94,19 +104,35 @@ export function OverviewTab() {
</PieChart>
</ResponsiveContainer>
<div className="flex-1">
<DataTable
<FilterableTable
data={devs}
sortOptions={[
{ key: 'cnt', label: '菜品数' },
{ key: 'deviation_band', label: '偏差区间' },
]}
defaultSort="cnt"
defaultOrder="desc"
columns={[
{ key: 'deviation_band', label: '偏差区间' },
{ key: 'cnt', label: '菜品数', align: 'right' },
]}
data={devs}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="成本差异TOP榜" subtitle="实际成本超理论最严重的菜品">
<DataTable
<FilterableTable
data={tops}
sortOptions={[
{ key: 'cost_variance', label: '成本差异' },
{ key: 'sales_amount', label: '销售额' },
{ key: 'actual_margin', label: '实际毛利率' },
{ key: 'theo_margin', label: '理论毛利率' },
]}
defaultSort="cost_variance"
filterKey="cost_tier"
filterLabel="全部分层"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
@@ -121,7 +147,6 @@ export function OverviewTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${tierColor(r.cost_tier)}`}>{r.cost_tier}</span>
)},
]}
data={tops}
/>
</CollapsibleSection>
</div>
@@ -2,22 +2,21 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
const PAGE_SIZE = 15
export function PackagingTab() {
export function PackagingTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('total_cost')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('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, sort, order, filter], queryFn: () => api.get(`/cost-analysis/packaging-detail?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}&filter=${filter}`) })
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/packaging-overview', month], queryFn: () => api.get('/cost-analysis/packaging-overview', { params: { month } }) })
const { data: detail, isLoading: l2 } = useQuery({ queryKey: ['ca/packaging-detail', month, page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/packaging-detail`, { params: { month, page, page_size: PAGE_SIZE, sort, order, filter } }) })
if (l1 || l2) return <LoadingSpinner text="加载包装耗材数据..." />
@@ -41,48 +40,46 @@ export function PackagingTab() {
</div>
<CollapsibleSection title="包装物料明细" subtitle="餐盒、餐具、打包袋等耗材使用情况">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={detailData}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
filter={filter}
onFilterChange={setFilter}
pageSize={PAGE_SIZE}
total={meta.total}
filterButtons={[
{ 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>
{[
]}
sortOptions={[
{ 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
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'total_qty', label: '总用量', align: 'right', render: (r) => formatNumber(r.total_qty) },
{ key: 'total_cost', label: '总成本', align: 'right', render: (r) => formatCurrency(r.total_cost) },
{ key: 'loss_qty', label: '损耗量', align: 'right', render: (r) => formatNumber(r.loss_qty) },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => {
const v = Number(r.avg_loss_rate || 0)
return <span className={v < -20 ? 'text-red-600' : v < -5 ? 'text-yellow-600' : 'text-green-600'}>{formatPercent(v)}</span>
}},
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{r.status}</span>
)},
]}
data={detailData}
/>
</div>
]}
defaultSort="total_cost"
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'total_qty', label: '总用量', align: 'right', render: (r) => formatNumber(r.total_qty) },
{ key: 'total_cost', label: '总成本', align: 'right', render: (r) => formatCurrency(r.total_cost) },
{ key: 'loss_qty', label: '损耗量', align: 'right', render: (r) => formatNumber(r.loss_qty) },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => {
const v = Number(r.avg_loss_rate || 0)
return <span className={v < -20 ? 'text-red-600' : v < -5 ? 'text-yellow-600' : 'text-green-600'}>{formatPercent(v)}</span>
}},
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{r.status}</span>
)},
]}
/>
</CollapsibleSection>
</div>
)
@@ -1,9 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis } from 'recharts'
@@ -17,16 +16,16 @@ const MENU_COLORS: Record<string, string> = {
'数据异常品': '#a3a3a3',
}
export function ProfitabilityTab() {
export function ProfitabilityTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [category, setCategory] = useState('')
const [sort, setSort] = useState('sales_amount')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('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, 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') })
const { data: matrix, isLoading: l1 } = useQuery({ queryKey: ['ca/menu-engineering', month], queryFn: () => api.get('/cost-analysis/menu-engineering', { params: { month } }) })
const { data: profit, isLoading: l2 } = useQuery({ queryKey: ['ca/profitability', month, page, category, sort, order, filter], queryFn: () => api.get(`/cost-analysis/profitability`, { params: { month, page, page_size: PAGE_SIZE, sort, order, filter, ...(category ? { category } : {}) } }) })
const { data: pricing, isLoading: l3 } = useQuery({ queryKey: ['ca/pricing', month], queryFn: () => api.get('/cost-analysis/pricing', { params: { month, threshold: 50 } }) })
if (l1 || l2 || l3) return <LoadingSpinner text="加载菜品盈利数据..." />
@@ -85,57 +84,67 @@ export function ProfitabilityTab() {
</CollapsibleSection>
<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>
<div className="mb-3 flex items-center gap-2">
<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>)}
</select>
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={profitMeta.total} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => formatNumber(r.sales_quantity) },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'theo_profit', label: '理论毛利', align: 'right', render: (r) => formatCurrency(r.theo_profit) },
{ key: 'actual_profit', label: '实际毛利', align: 'right', render: (r) => formatCurrency(r.actual_profit) },
{ key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theo_margin) },
{ key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) },
{ key: 'revenue_contribution', label: '收入贡献度', align: 'right', render: (r) => formatPercent(r.revenue_contribution) },
]}
data={profitData}
/>
</div>
<FilterableTable
data={profitData}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
filter={filter}
onFilterChange={setFilter}
pageSize={PAGE_SIZE}
total={profitMeta.total}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'profitable', label: '盈利菜品' },
{ key: 'loss', label: '亏损菜品' },
{ key: 'high_variance', label: '成本超耗' },
]}
sortOptions={[
{ 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: '成本差异' },
]}
defaultSort="sales_amount"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => formatNumber(r.sales_quantity) },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'theo_profit', label: '理论毛利', align: 'right', render: (r) => formatCurrency(r.theo_profit) },
{ key: 'actual_profit', label: '实际毛利', align: 'right', render: (r) => formatCurrency(r.actual_profit) },
{ key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theo_margin) },
{ key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) },
{ key: 'revenue_contribution', label: '收入贡献度', align: 'right', render: (r) => formatPercent(r.revenue_contribution) },
]}
/>
</CollapsibleSection>
<CollapsibleSection title="低毛利菜品预警" subtitle="理论毛利率 < 50% 且销售额 > 1000" defaultOpen={false}>
<DataTable
<FilterableTable
data={pricingData}
sortOptions={[
{ key: 'theo_margin', label: '理论毛利率' },
{ key: 'actual_margin', label: '实际毛利率' },
{ key: 'sales_amount', label: '销售额' },
{ key: 'price', label: '售价' },
]}
defaultSort="theo_margin"
defaultOrder="asc"
filterKey="category_level1"
filterLabel="全部分类"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
@@ -146,7 +155,6 @@ export function ProfitabilityTab() {
{ key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
]}
data={pricingData}
/>
</CollapsibleSection>
</div>
@@ -2,25 +2,24 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatPercent, formatNumber, formatCurrency } from '@/lib/utils'
import { useState } from 'react'
const PAGE_SIZE = 15
export function QualityTab() {
export function QualityTab({ month }: { month: string }) {
const [dishPage, setDishPage] = useState(1)
const [matPage, setMatPage] = useState(1)
const [dishSort, setDishSort] = useState('sales_amount')
const [dishOrder, setDishOrder] = useState('desc')
const [dishOrder, setDishOrder] = useState<'asc' | 'desc'>('desc')
const [matSort, setMatSort] = useState('dish_count')
const [matOrder, setMatOrder] = useState('desc')
const [matOrder, setMatOrder] = useState<'asc' | 'desc'>('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, 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}`) })
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/data-quality', month], queryFn: () => api.get('/cost-analysis/data-quality', { params: { month } }) })
const { data: dishes, isLoading: l2 } = useQuery({ queryKey: ['ca/unmatched-dishes', month, dishPage, dishSort, dishOrder], queryFn: () => api.get(`/cost-analysis/unmatched-dishes`, { params: { month, page: dishPage, page_size: PAGE_SIZE, sort: dishSort, order: dishOrder } }) })
const { data: materials, isLoading: l3 } = useQuery({ queryKey: ['ca/unmatched-materials', month, matPage, matSort, matOrder], queryFn: () => api.get(`/cost-analysis/unmatched-materials`, { params: { month, page: matPage, page_size: PAGE_SIZE, sort: matSort, order: matOrder } }) })
if (l1 || l2 || l3) return <LoadingSpinner text="加载数据质量..." />
@@ -53,7 +52,16 @@ export function QualityTab() {
</div>
<CollapsibleSection title="BOM数据质量检查" subtitle="每月固定检查项">
<DataTable
<FilterableTable
data={qualityChecks}
sortOptions={[
{ key: 'check', label: '检查项' },
{ key: 'status', label: '状态' },
]}
defaultSort="check"
defaultOrder="asc"
filterKey="status"
filterLabel="全部状态"
columns={[
{ key: 'check', label: '检查项' },
{ key: 'value', label: '值', align: 'right' },
@@ -62,63 +70,60 @@ export function QualityTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${checkColor(r.status)}`}>{r.status}</span>
)},
]}
data={qualityChecks}
/>
</CollapsibleSection>
<CollapsibleSection title="未匹配菜品清单" subtitle="菜品成本报表中未匹配到销售数据的菜品" defaultOpen={false}>
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={dishData}
serverSide
page={dishPage}
onPageChange={setDishPage}
sort={dishSort}
onSortChange={setDishSort}
order={dishOrder}
onOrderChange={setDishOrder}
pageSize={PAGE_SIZE}
total={dishMeta.total}
sortOptions={[
{ 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
columns={[
{ key: 'dish_name', label: '菜品名' },
{ key: 'dish_code', label: '编码' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
]}
data={dishData}
/>
</div>
]}
defaultSort="sales_amount"
columns={[
{ key: 'dish_name', label: '菜品名' },
{ key: 'dish_code', label: '编码' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
]}
/>
</CollapsibleSection>
<CollapsibleSection title="未匹配物料清单" subtitle="未匹配到库存数据的物料" defaultOpen={false}>
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={matData}
serverSide
page={matPage}
onPageChange={setMatPage}
sort={matSort}
onSortChange={setMatSort}
order={matOrder}
onOrderChange={setMatOrder}
pageSize={PAGE_SIZE}
total={matMeta.total}
sortOptions={[
{ 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
columns={[
{ key: 'material_name', label: '物料名' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.loss_amount) },
]}
data={matData}
/>
</div>
]}
defaultSort="dish_count"
columns={[
{ key: 'material_name', label: '物料名' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.loss_amount) },
]}
/>
</CollapsibleSection>
</div>
)
@@ -2,13 +2,11 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
const LEVEL_COLORS: Record<string, string> = {
@@ -18,15 +16,14 @@ const LEVEL_COLORS: Record<string, string> = {
'灰色-口径异常': '#a3a3a3',
}
export function StoreTab() {
export function StoreTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('food_cost_variance')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const [filter, setFilter] = useState('')
const [month, setMonth] = useState('2026-04')
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/store-overview', month], queryFn: () => api.get('/cost-analysis/store-overview', { params: { month } }) })
const { data: ranking, isLoading: l2 } = useQuery({ queryKey: ['ca/store-ranking', month, page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/store-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}&filter=${filter}&month=${month}`) })
const { data: ranking, isLoading: l2 } = useQuery({ queryKey: ['ca/store-ranking', month, page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/store-ranking`, { params: { month, page, page_size: PAGE_SIZE, sort, order, filter } }) })
if (l1 || l2) return <LoadingSpinner text="加载门店成本数据..." />
@@ -50,9 +47,6 @@ export function StoreTab() {
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<MonthPicker month={month} onChange={setMonth} />
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="门店总数" value={ovData.total_stores} format="number" />
<MetricCard title="红色-严重超耗" value={ovData.red_count} format="number" />
@@ -74,56 +68,54 @@ export function StoreTab() {
</CollapsibleSection>
<CollapsibleSection title="门店超耗排名" subtitle="支持筛选和排序">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={rankData}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
filter={filter}
onFilterChange={setFilter}
pageSize={PAGE_SIZE}
total={meta.total}
filterButtons={[
{ 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>
{[
]}
sortOptions={[
{ 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
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theo_cost_rate', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theo_cost_rate) },
{ key: 'actual_cost_rate', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_cost_rate) },
{ key: 'variance_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_pct || 0)
return <span className={v > 20 ? 'text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{formatPercent(v)}</span>
}},
{ key: 'variance_amount', label: '超耗金额', align: 'right', render: (r) => {
const v = Number(r.variance_amount || 0)
return <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
{ key: 'negative_item_lines', label: '负库存项', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
{ key: 'variance_level', label: '分级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${levelColor(r.variance_level)}`}>{r.variance_level}</span>
)},
]}
data={rankData}
/>
</div>
]}
defaultSort="food_cost_variance"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theo_cost_rate', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theo_cost_rate) },
{ key: 'actual_cost_rate', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_cost_rate) },
{ key: 'variance_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_pct || 0)
return <span className={v > 20 ? 'text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{formatPercent(v)}</span>
}},
{ key: 'variance_amount', label: '超耗金额', align: 'right', render: (r) => {
const v = Number(r.variance_amount || 0)
return <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
{ key: 'negative_item_lines', label: '负库存项', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
{ key: 'variance_level', label: '分级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${levelColor(r.variance_level)}`}>{r.variance_level}</span>
)},
]}
/>
</CollapsibleSection>
</div>
)
@@ -1,31 +1,30 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
const PAGE_SIZE = 15
export function SupplyTab() {
export function SupplyTab({ month }: { month: string }) {
const [riskPage, setRiskPage] = useState(1)
const [demandPage, setDemandPage] = useState(1)
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 [riskOrder, setRiskOrder] = useState<'asc' | 'desc'>('asc')
const [riskFilter, setRiskFilter] = useState('')
const [demandSort, setDemandSort] = useState('estimated_demand')
const [demandOrder, setDemandOrder] = useState('desc')
const [demandOrder, setDemandOrder] = useState<'asc' | 'desc'>('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, 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') })
const { data: sharing, isLoading: l1 } = useQuery({ queryKey: ['ca/material-sharing', month], queryFn: () => api.get('/cost-analysis/material-sharing', { params: { month, limit: 30 } }) })
const { data: risk, isLoading: l2 } = useQuery({ queryKey: ['ca/unique-material-risk', month, riskPage, riskSort, riskOrder, riskFilter], queryFn: () => api.get(`/cost-analysis/unique-material-risk`, { params: { month, page: riskPage, page_size: PAGE_SIZE, sort: riskSort, order: riskOrder, filter: riskFilter } }) })
const { data: demand, isLoading: l3 } = useQuery({ queryKey: ['ca/material-demand', month], queryFn: () => api.get('/cost-analysis/material-demand', { params: { month, limit: 100 } }) })
if (l1 || l2 || l3) return <LoadingSpinner text="加载供应链数据..." />
@@ -33,12 +32,6 @@ export function SupplyTab() {
const riskData = (risk as any)?.data || []
const riskMeta = (risk as any)?.meta || { total: 0 }
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'
@@ -92,7 +85,16 @@ export function SupplyTab() {
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
<FilterableTable
data={sharingData}
sortOptions={[
{ key: 'sku_count', label: '被引用SKU数' },
{ key: 'material_name', label: '物料名' },
]}
defaultSort="sku_count"
defaultOrder="desc"
filterKey="sharing_type"
filterLabel="全部分类"
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'major_category', label: '类型', align: 'center' },
@@ -101,48 +103,46 @@ export function SupplyTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${sharingColor(r.sharing_type)}`}>{r.sharing_type}</span>
)},
]}
data={sharingData}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="独有原料风险" subtitle="低收入SKU占用专用原料">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={riskData}
serverSide
page={riskPage}
onPageChange={setRiskPage}
sort={riskSort}
onSortChange={setRiskSort}
order={riskOrder}
onOrderChange={setRiskOrder}
filter={riskFilter}
onFilterChange={setRiskFilter}
pageSize={PAGE_SIZE}
total={riskMeta.total}
filterButtons={[
{ 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>
{[
]}
sortOptions={[
{ 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
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'unique_material_count', label: '独有原料数', align: 'right', render: (r) => <span className={r.unique_material_count > 3 ? 'text-red-600' : ''}>{formatNumber(r.unique_material_count)}</span> },
{ key: 'risk_level', label: '风险等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${riskLevelColor(r.risk_level)}`}>{r.risk_level}</span>
)},
]}
data={riskData}
/>
</div>
]}
defaultSort="sales_amount"
defaultOrder="asc"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'unique_material_count', label: '独有原料数', align: 'right', render: (r) => <span className={r.unique_material_count > 3 ? 'text-red-600' : ''}>{formatNumber(r.unique_material_count)}</span> },
{ key: 'risk_level', label: '风险等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${riskLevelColor(r.risk_level)}`}>{r.risk_level}</span>
)},
]}
/>
</CollapsibleSection>
<CollapsibleSection title="SKU精简模拟器" subtitle="输入SKU编码模拟下架影响" defaultOpen={false}>
@@ -167,13 +167,21 @@ export function SupplyTab() {
<MetricCard title="负毛利菜品数" value={simResult.summary?.negative_margin_count} format="number" />
</div>
{simResult.releasable_materials?.length > 0 && (
<DataTable
<FilterableTable
data={simResult.releasable_materials}
sortOptions={[
{ key: 'inventory_value', label: '库存金额' },
{ key: 'material_name', label: '物料名' },
]}
defaultSort="inventory_value"
defaultOrder="desc"
filterKey="major_category"
filterLabel="全部类型"
columns={[
{ key: 'material_name', label: '可释放物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'inventory_value', label: '库存金额', align: 'right', render: (r) => formatCurrency(r.inventory_value) },
]}
data={simResult.releasable_materials}
/>
)}
</>
@@ -182,32 +190,23 @@ export function SupplyTab() {
</CollapsibleSection>
<CollapsibleSection title="BOM驱动物料需求预测" subtitle="按历史销量×标准用量估算" defaultOpen={false}>
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={rawDemandData}
sortOptions={[
{ 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
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'base_unit', label: '单位', align: 'center' },
{ key: 'estimated_demand', label: '预计需求量', align: 'right', render: (r) => formatNumber(r.estimated_demand) },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
]}
data={demandData.slice((demandPage - 1) * PAGE_SIZE, demandPage * PAGE_SIZE)}
/>
</div>
]}
defaultSort="estimated_demand"
pageSize={PAGE_SIZE}
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'base_unit', label: '单位', align: 'center' },
{ key: 'estimated_demand', label: '预计需求量', align: 'right', render: (r) => formatNumber(r.estimated_demand) },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
]}
/>
</CollapsibleSection>
</div>
)
@@ -1,39 +1,25 @@
import { useState, useMemo } 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatNumber } from '@/lib/utils'
const PAGE_SIZE = 20
const LEVEL_BG: Record<string, string> = {
red: 'bg-red-100 text-red-700 border-red-300',
orange: 'bg-orange-100 text-orange-700 border-orange-300',
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-300',
}
export function AttendanceAlertTab() {
const [summaryPage, setSummaryPage] = useState(1)
const [alertPage, setAlertPage] = useState(1)
const [summaryFilter, setSummaryFilter] = useState('')
const [summarySort, setSummarySort] = useState('emp_count')
const [summaryOrder, setSummaryOrder] = useState('desc')
const [alertFilter, setAlertFilter] = useState('')
const [alertLevelFilter, setAlertLevelFilter] = useState('')
const [alertSort, setAlertSort] = useState('attend_diff')
const [alertOrder, setAlertOrder] = useState('desc')
export function AttendanceAlertTab({ month }: { month: string }) {
const { data: summaryData, isLoading: summaryLoading } = useQuery({
queryKey: ['ss/attendance-summary'],
queryFn: () => api.get('/smart-scheduling/attendance-summary'),
queryKey: ['ss/attendance-summary', month],
queryFn: () => api.get('/smart-scheduling/attendance-summary', { params: { month } }),
})
const { data: alertData, isLoading: alertLoading } = useQuery({
queryKey: ['ss/attendance-alert'],
queryFn: () => api.get('/smart-scheduling/attendance-alert'),
queryKey: ['ss/attendance-alert', month],
queryFn: () => api.get('/smart-scheduling/attendance-alert', { params: { month } }),
})
const summaryRows = (summaryData as any)?.data || []
@@ -41,49 +27,6 @@ export function AttendanceAlertTab() {
const storeNames = [...new Set(summaryRows.map((r: any) => r.store_name))].sort() as string[]
const summarySorts = [
{ key: 'emp_count', label: '人数' },
{ key: 'avg_attend_days', label: '均出勤天' },
{ key: 'avg_hours', label: '均工时' },
{ key: 'total_absent_days', label: '旷工天数' },
{ key: 'total_late_deduction', label: '迟到扣款' },
{ key: 'low_attendance_rate_pct', label: '低出勤率' },
]
const summaryFiltered = useMemo(() => {
let r = summaryRows
if (summaryFilter) r = r.filter((s: any) => s.store_name === summaryFilter)
return [...r].sort((a: any, b: any) => {
const av = parseFloat(a[summarySort]) || 0
const bv = parseFloat(b[summarySort]) || 0
return summaryOrder === 'desc' ? bv - av : av - bv
})
}, [summaryRows, summaryFilter, summarySort, summaryOrder])
const summaryTotal = summaryFiltered.length
const summaryPaged = summaryFiltered.slice((summaryPage - 1) * PAGE_SIZE, summaryPage * PAGE_SIZE)
const alertSorts = [
{ key: 'attend_diff', label: '偏差天' },
{ key: 'absent_days', label: '旷工天' },
{ key: 'late_deduction', label: '迟到扣款' },
{ key: 'actual_hours', label: '实际工时' },
]
const alertFiltered = useMemo(() => {
let r = alertRows
if (alertFilter) r = r.filter((s: any) => s.store_name === alertFilter)
if (alertLevelFilter) r = r.filter((s: any) => s.alert_level === alertLevelFilter)
return [...r].sort((a: any, b: any) => {
const av = parseFloat(a[alertSort]) || 0
const bv = parseFloat(b[alertSort]) || 0
return alertOrder === 'desc' ? bv - av : av - bv
})
}, [alertRows, alertFilter, alertLevelFilter, alertSort, alertOrder])
const alertTotal = alertFiltered.length
const alertPaged = alertFiltered.slice((alertPage - 1) * PAGE_SIZE, alertPage * PAGE_SIZE)
const redCount = alertRows.filter((r: any) => r.alert_level === 'red').length
const orangeCount = alertRows.filter((r: any) => r.alert_level === 'orange').length
const yellowCount = alertRows.filter((r: any) => r.alert_level === 'yellow').length
@@ -108,21 +51,20 @@ export function AttendanceAlertTab() {
</div>
<CollapsibleSection title="门店考勤汇总" subtitle="各门店出勤天数、旷工、迟到扣款、低出勤率">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={summaryFilter} onChange={(e) => { setSummaryFilter(e.target.value); setSummaryPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{summarySorts.map(s => (
<button key={s.key} onClick={() => { setSummarySort(s.key); setSummaryPage(1) }} className={`px-3 py-1 rounded text-xs ${summarySort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setSummaryOrder(summaryOrder === 'asc' ? 'desc' : 'asc'); setSummaryPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{summaryOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={summaryRows}
sortOptions={[
{ key: 'emp_count', label: '人数' },
{ key: 'avg_attend_days', label: '均出勤天' },
{ key: 'avg_hours', label: '均工时' },
{ key: 'total_absent_days', label: '旷工天数' },
{ key: 'total_late_deduction', label: '迟到扣款' },
{ key: 'low_attendance_rate_pct', label: '低出勤率' },
]}
defaultSort="emp_count"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
@@ -134,33 +76,29 @@ export function AttendanceAlertTab() {
{ key: 'punch_issue_count', label: '打卡异常人数', align: 'right', render: (r) => <span className={parseFloat(r.punch_issue_count) > 5 ? 'text-orange-600' : ''}>{r.punch_issue_count || 0}</span> },
{ key: 'low_attendance_rate_pct', label: '低出勤率%', align: 'right', render: (r) => <span className={parseFloat(r.low_attendance_rate_pct) > 20 ? 'text-red-600 font-medium' : ''}>{r.low_attendance_rate_pct ? r.low_attendance_rate_pct + '%' : '0%'}</span> },
]}
data={summaryPaged}
/>
<Pagination page={summaryPage} pageSize={PAGE_SIZE} total={summaryTotal} onPageChange={setSummaryPage} />
</CollapsibleSection>
<CollapsibleSection title="考勤异常明细" subtitle="员工级考勤预警列表(按严重程度排序)">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={alertFilter} onChange={(e) => { setAlertFilter(e.target.value); setAlertPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select value={alertLevelFilter} onChange={(e) => { setAlertLevelFilter(e.target.value); setAlertPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
<option value="red"></option>
<option value="orange"></option>
<option value="yellow"></option>
</select>
<span className="mx-2 text-muted-foreground">|</span>
{alertSorts.map(s => (
<button key={s.key} onClick={() => { setAlertSort(s.key); setAlertPage(1) }} className={`px-3 py-1 rounded text-xs ${alertSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setAlertOrder(alertOrder === 'asc' ? 'desc' : 'asc'); setAlertPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{alertOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={alertRows}
sortOptions={[
{ key: 'attend_diff', label: '偏差天' },
{ key: 'absent_days', label: '旷工天' },
{ key: 'late_deduction', label: '迟到扣款' },
{ key: 'actual_hours', label: '实际工时' },
]}
defaultSort="attend_diff"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
statusFilterKey="alert_level"
statusFilterLabel="全部等级"
statusOptions={[
{ value: 'red', label: '红色' },
{ value: 'orange', label: '橙色' },
{ value: 'yellow', label: '黄色' },
]}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'employee_code', label: '工号' },
@@ -174,9 +112,7 @@ export function AttendanceAlertTab() {
{ key: 'actual_hours', label: '实际工时', align: 'right', render: (r) => formatNumber(r.actual_hours) },
{ key: 'alert_type', label: '预警类型', render: (r) => <span className={`px-2 py-0.5 rounded text-xs border ${LEVEL_BG[r.alert_level] || ''}`}>{r.alert_type}</span> },
]}
data={alertPaged}
/>
<Pagination page={alertPage} pageSize={PAGE_SIZE} total={alertTotal} onPageChange={setAlertPage} />
</CollapsibleSection>
</div>
)
@@ -1,82 +1,57 @@
import { useState, useMemo } from 'react'
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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
const PAGE_SIZE = 20
export function EfficiencyBenchmarkTab() {
export function EfficiencyBenchmarkTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('revenue_per_emp')
const [order, setOrder] = useState('desc')
const [posPage, setPosPage] = useState(1)
const [posFilter, setPosFilter] = useState('')
const [posSort, setPosSort] = useState('total_emp')
const [posOrder, setPosOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({
queryKey: ['ss/efficiency', page, sort, order],
queryFn: () => api.get(`/smart-scheduling/efficiency-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}`),
queryKey: ['ss/efficiency', page, sort, order, month],
queryFn: () => api.get(`/smart-scheduling/efficiency-ranking`, { params: { page, page_size: PAGE_SIZE, sort, order, month } }),
})
const { data: posData } = useQuery({
queryKey: ['ss/position-distribution'],
queryFn: () => api.get('/smart-scheduling/position-distribution'),
queryKey: ['ss/position-distribution', month],
queryFn: () => api.get('/smart-scheduling/position-distribution', { params: { month } }),
})
const rows = (data as any)?.data || []
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
const posRows = (posData as any)?.data || []
const posStoreNames = [...new Set(posRows.map((r: any) => r.store_name))].sort() as string[]
const posSorts = [
{ key: 'total_emp', label: '总人数' },
{ key: 'manager_pct', label: '管理占比' },
{ key: 'kitchen_pct', label: '后厨占比' },
{ key: 'front_pct', label: '前厅占比' },
{ key: 'part_time_count', label: '兼职数' },
]
const posFiltered = useMemo(() => {
let r = posRows
if (posFilter) r = r.filter((s: any) => s.store_name === posFilter)
return [...r].sort((a: any, b: any) => {
const av = parseFloat(a[posSort]) || 0
const bv = parseFloat(b[posSort]) || 0
return posOrder === 'desc' ? bv - av : av - bv
})
}, [posRows, posFilter, posSort, posOrder])
const posTotal = posFiltered.length
const posPaged = posFiltered.slice((posPage - 1) * PAGE_SIZE, posPage * PAGE_SIZE)
if (isLoading) return <LoadingSpinner text="加载人效数据..." />
const sorts = [
{ key: 'revenue_per_emp', label: '人均创收' },
{ key: 'emp_count', label: '人数' },
{ key: 'gross_pay', label: '应发工资' },
{ key: 'wage_rate', label: '人力成本率' },
{ key: 'avg_hours', label: '人均工时' },
]
return (
<div className="space-y-4">
<CollapsibleSection title="门店人效排名" subtitle="人均创收、人力成本率、人均工时、加班占比">
<div className="flex gap-2 mb-3 flex-wrap">
{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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
sortOptions={[
{ key: 'revenue_per_emp', label: '人均创收' },
{ key: 'emp_count', label: '人数' },
{ key: 'gross_pay', label: '应发工资' },
{ key: 'wage_rate', label: '人力成本率' },
{ key: 'avg_hours', label: '人均工时' },
]}
defaultSort="revenue_per_emp"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
@@ -89,27 +64,23 @@ export function EfficiencyBenchmarkTab() {
{ key: 'revenue_per_hour', label: '时均创收', align: 'right', render: (r) => formatCurrency(r.revenue_per_hour) },
{ key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => <span className={parseFloat(r.overtime_rate) > 5 ? 'text-orange-600' : ''}>{formatPercent(r.overtime_rate)}</span> },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
<CollapsibleSection title="岗位配比分析" subtitle="各门店前厅/后厨/管理岗位人数及占比">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={posFilter} onChange={(e) => { setPosFilter(e.target.value); setPosPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{posStoreNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{posSorts.map(s => (
<button key={s.key} onClick={() => { setPosSort(s.key); setPosPage(1) }} className={`px-3 py-1 rounded text-xs ${posSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setPosOrder(posOrder === 'asc' ? 'desc' : 'asc'); setPosPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{posOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={posRows}
sortOptions={[
{ key: 'total_emp', label: '总人数' },
{ key: 'manager_pct', label: '管理占比' },
{ key: 'kitchen_pct', label: '后厨占比' },
{ key: 'front_pct', label: '前厅占比' },
{ key: 'part_time_count', label: '兼职数' },
]}
defaultSort="total_emp"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'total_emp', label: '总人数', align: 'right', render: (r) => formatNumber(r.total_emp) },
@@ -121,9 +92,7 @@ export function EfficiencyBenchmarkTab() {
{ key: 'front_pct', label: '前厅占比%', align: 'right', render: (r) => `${r.front_pct}%` },
{ key: 'part_time_count', label: '兼职', align: 'right', render: (r) => formatNumber(r.part_time_count) },
]}
data={posPaged}
/>
<Pagination page={posPage} pageSize={PAGE_SIZE} total={posTotal} onPageChange={setPosPage} />
</CollapsibleSection>
</div>
)
@@ -1,9 +1,8 @@
import { useState, useMemo } from 'react'
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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatNumber } from '@/lib/utils'
@@ -15,105 +14,64 @@ const STATUS_COLORS: Record<string, string> = {
'在职': 'text-green-600',
}
export function EmployeeAnalysisTab() {
export function EmployeeAnalysisTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('gross_pay')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const [storeFilter, setStoreFilter] = useState('')
const [posPage, setPosPage] = useState(1)
const [turnoverPage, setTurnoverPage] = useState(1)
const [posSort, setPosSort] = useState('avg_gross')
const [posOrder, setPosOrder] = useState('desc')
const [turnoverSort, setTurnoverSort] = useState('turnover_rate')
const [turnoverOrder, setTurnoverOrder] = useState('desc')
const { data, isLoading } = useQuery({
queryKey: ['ss/employee', page, sort, order, storeFilter],
queryFn: () => api.get(`/smart-scheduling/employee-analysis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}${storeFilter ? `&store=${storeFilter}` : ''}`),
queryKey: ['ss/employee', page, sort, order, storeFilter, month],
queryFn: () => api.get(`/smart-scheduling/employee-analysis`, { params: { page, page_size: PAGE_SIZE, sort, order, month, ...(storeFilter ? { store: storeFilter } : {}) } }),
})
const { data: posData } = useQuery({
queryKey: ['ss/position-salary'],
queryFn: () => api.get('/smart-scheduling/position-salary-compare'),
queryKey: ['ss/position-salary', month],
queryFn: () => api.get('/smart-scheduling/position-salary-compare', { params: { month } }),
})
const { data: turnoverData } = useQuery({
queryKey: ['ss/turnover'],
queryFn: () => api.get('/smart-scheduling/turnover-stats'),
queryKey: ['ss/turnover', month],
queryFn: () => api.get('/smart-scheduling/turnover-stats', { params: { month } }),
})
const rows = (data as any)?.data || []
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
const posRows = (posData as any)?.data || []
const turnoverRows = (turnoverData as any)?.data || []
const posSorts = [
{ key: 'avg_gross', label: '均应发' },
{ key: 'emp_count', label: '人数' },
{ key: 'store_count', label: '门店数' },
{ key: 'avg_hourly_rate', label: '均时薪' },
{ key: 'avg_hours', label: '均工时' },
]
const posFiltered = useMemo(() => {
return [...posRows].sort((a: any, b: any) => {
const av = parseFloat(a[posSort]) || 0
const bv = parseFloat(b[posSort]) || 0
return posOrder === 'desc' ? bv - av : av - bv
})
}, [posRows, posSort, posOrder])
const posTotal = posFiltered.length
const posPaged = posFiltered.slice((posPage - 1) * PAGE_SIZE, posPage * PAGE_SIZE)
const turnoverSorts = [
{ key: 'turnover_rate', label: '离职率' },
{ key: 'left_count', label: '离职人数' },
{ key: 'new_hire_rate', label: '新员工占比' },
{ key: 'total_emp', label: '总人数' },
]
const turnoverFiltered = useMemo(() => {
return [...turnoverRows].sort((a: any, b: any) => {
const av = parseFloat(a[turnoverSort]) || 0
const bv = parseFloat(b[turnoverSort]) || 0
return turnoverOrder === 'desc' ? bv - av : av - bv
})
}, [turnoverRows, turnoverSort, turnoverOrder])
const turnoverTotal = turnoverFiltered.length
const turnoverPaged = turnoverFiltered.slice((turnoverPage - 1) * PAGE_SIZE, turnoverPage * PAGE_SIZE)
const storeNames = [...new Set(turnoverRows.map((r: any) => r.store_name))].sort() as string[]
if (isLoading) return <LoadingSpinner text="加载员工数据..." />
const sorts = [
{ key: 'gross_pay', label: '应发工资' },
{ key: 'net_pay', label: '实发工资' },
{ key: 'actual_hours', label: '工时' },
{ key: 'perf_amount', label: '绩效' },
{ key: 'overtime_pay', label: '加班费' },
{ key: 'hourly_rate', label: '时薪' },
]
return (
<div className="space-y-4">
<CollapsibleSection title="员工薪资明细" subtitle="员工级薪资构成、工时、绩效、有效时薪">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={storeFilter} onChange={(e) => { setStoreFilter(e.target.value); setPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s: any) => <option key={s} value={s}>{s}</option>)}
</select>
<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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={storeFilter}
onFilterChange={setStoreFilter}
filterKey="store_name"
filterLabel="全部门店"
filterOptions={storeNames}
sortOptions={[
{ key: 'gross_pay', label: '应发工资' },
{ key: 'net_pay', label: '实发工资' },
{ key: 'actual_hours', label: '工时' },
{ key: 'perf_amount', label: '绩效' },
{ key: 'overtime_pay', label: '加班费' },
{ key: 'hourly_rate', label: '时薪' },
]}
defaultSort="gross_pay"
defaultOrder="desc"
columns={[
{ key: 'employee_code', label: '工号' },
{ key: 'store_name', label: '门店' },
@@ -129,22 +87,24 @@ export function EmployeeAnalysisTab() {
{ key: 'effective_hourly_rate', label: '有效时薪', align: 'right', render: (r) => <span className={parseFloat(r.effective_hourly_rate) > 100 ? 'text-green-600' : parseFloat(r.effective_hourly_rate) < 50 ? 'text-red-600' : ''}>{formatCurrency(r.effective_hourly_rate)}</span> },
{ key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => <span className={parseFloat(r.overtime_rate) > 5 ? 'text-orange-600' : ''}>{r.overtime_rate}%</span> },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
<CollapsibleSection title="岗位薪资对比" subtitle="相同岗位在不同门店的薪资水平差异">
<div className="flex gap-2 mb-3 flex-wrap">
{posSorts.map(s => (
<button key={s.key} onClick={() => { setPosSort(s.key); setPosPage(1) }} className={`px-3 py-1 rounded text-xs ${posSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setPosOrder(posOrder === 'asc' ? 'desc' : 'asc'); setPosPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{posOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={posRows}
filterKey="position"
filterLabel="全部岗位"
sortOptions={[
{ key: 'avg_gross', label: '均应发' },
{ key: 'emp_count', label: '人数' },
{ key: 'store_count', label: '门店数' },
{ key: 'avg_hourly_rate', label: '均时薪' },
{ key: 'avg_hours', label: '均工时' },
]}
defaultSort="avg_gross"
defaultOrder="desc"
pageSize={PAGE_SIZE}
columns={[
{ key: 'position', label: '岗位' },
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
@@ -157,22 +117,22 @@ export function EmployeeAnalysisTab() {
{ key: 'avg_perf_score', label: '均绩效分', align: 'right', render: (r) => r.avg_perf_score },
{ key: 'avg_hourly_rate', label: '均有效时薪', align: 'right', render: (r) => formatCurrency(r.avg_hourly_rate) },
]}
data={posPaged}
/>
<Pagination page={posPage} pageSize={PAGE_SIZE} total={posTotal} onPageChange={setPosPage} />
</CollapsibleSection>
<CollapsibleSection title="离职率统计" subtitle="各门店离职率、新员工占比">
<div className="flex gap-2 mb-3 flex-wrap">
{turnoverSorts.map(s => (
<button key={s.key} onClick={() => { setTurnoverSort(s.key); setTurnoverPage(1) }} className={`px-3 py-1 rounded text-xs ${turnoverSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setTurnoverOrder(turnoverOrder === 'asc' ? 'desc' : 'asc'); setTurnoverPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{turnoverOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={turnoverRows}
sortOptions={[
{ key: 'turnover_rate', label: '离职率' },
{ key: 'left_count', label: '离职人数' },
{ key: 'new_hire_rate', label: '新员工占比' },
{ key: 'total_emp', label: '总人数' },
]}
defaultSort="turnover_rate"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'total_emp', label: '总人数', align: 'right', render: (r) => formatNumber(r.total_emp) },
@@ -181,9 +141,7 @@ export function EmployeeAnalysisTab() {
{ key: 'new_count', label: '新员工数', align: 'right', render: (r) => <span className="text-blue-500">{r.new_count}</span> },
{ key: 'new_hire_rate', label: '新员工占比%', align: 'right', render: (r) => <span className={parseFloat(r.new_hire_rate) > 20 ? 'text-orange-600' : ''}>{r.new_hire_rate ? r.new_hire_rate + '%' : '0%'}</span> },
]}
data={turnoverPaged}
/>
<Pagination page={turnoverPage} pageSize={PAGE_SIZE} total={turnoverTotal} onPageChange={setTurnoverPage} />
</CollapsibleSection>
</div>
)
@@ -18,10 +18,10 @@ const CATEGORY_LABELS: Record<string, string> = {
'离职': '人员稳定',
}
export function OverallAnalysisTab() {
export function OverallAnalysisTab({ month }: { month: string }) {
const { data, isLoading } = useQuery({
queryKey: ['ss/overall-analysis'],
queryFn: () => api.get('/smart-scheduling/overall-analysis'),
queryKey: ['ss/overall-analysis', month],
queryFn: () => api.get('/smart-scheduling/overall-analysis', { params: { month } }),
})
if (isLoading) return <LoadingSpinner text="生成总体智能分析..." />
@@ -2,7 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatNumber } from '@/lib/utils'
@@ -56,7 +56,7 @@ const tableColumns = [
{ key: 'action', label: '建议', render: (r: any) => <span className={ACTION_COLORS[r.action] || ''}>{r.action}</span> },
]
export function SchedulingSuggestionTab() {
export function SchedulingSuggestionTab({ month }: { month: string }) {
const { data: storesData } = useQuery({
queryKey: ['ss/stores'],
queryFn: () => api.get('/smart-scheduling/stores'),
@@ -67,8 +67,8 @@ export function SchedulingSuggestionTab() {
const [kitchenTarget, setKitchenTarget] = useState(25)
const { data, isLoading } = useQuery({
queryKey: ['ss/suggestion', storeName, frontTarget, kitchenTarget],
queryFn: () => api.get(`/smart-scheduling/scheduling-suggestion?store=${storeName}&front_target=${frontTarget}&kitchen_target=${kitchenTarget}`),
queryKey: ['ss/suggestion', storeName, frontTarget, kitchenTarget, month],
queryFn: () => api.get(`/smart-scheduling/scheduling-suggestion?store=${storeName}&front_target=${frontTarget}&kitchen_target=${kitchenTarget}&month=${month}`),
enabled: !!storeName,
})
@@ -123,11 +123,35 @@ export function SchedulingSuggestionTab() {
<div className="space-y-4">
<div>
<h3 className="text-sm font-bold mb-2"></h3>
<DataTable columns={tableColumns} data={workdayRows} rowClassName={(r) => ROW_BG[r.action] || ''} />
<FilterableTable
data={workdayRows}
sortOptions={[
{ key: 'hour', label: '时段' },
{ key: 'avg_daily_bills', label: '日均账单' },
{ key: 'staff_gap', label: '总缺口' },
]}
defaultSort="hour"
defaultOrder="asc"
filterKey="action"
filterLabel="全部建议"
columns={tableColumns}
/>
</div>
<div>
<h3 className="text-sm font-bold mb-2"></h3>
<DataTable columns={tableColumns} data={weekendRows} rowClassName={(r) => ROW_BG[r.action] || ''} />
<FilterableTable
data={weekendRows}
sortOptions={[
{ key: 'hour', label: '时段' },
{ key: 'avg_daily_bills', label: '日均账单' },
{ key: 'staff_gap', label: '总缺口' },
]}
defaultSort="hour"
defaultOrder="asc"
filterKey="action"
filterLabel="全部建议"
columns={tableColumns}
/>
</div>
</div>
</>
@@ -20,7 +20,7 @@ const LEVEL_ROW_STYLES: Record<string, string> = {
yellow: 'bg-yellow-50/30',
}
export function StaffingForecastTab() {
export function StaffingForecastTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [storeFilter, setStoreFilter] = useState('')
const [actionFilter, setActionFilter] = useState('')
@@ -30,8 +30,8 @@ export function StaffingForecastTab() {
const [showRules, setShowRules] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['ss/staffing-forecast'],
queryFn: () => api.get('/smart-scheduling/staffing-forecast'),
queryKey: ['ss/staffing-forecast', month],
queryFn: () => api.get('/smart-scheduling/staffing-forecast', { params: { month } }),
})
const result = (data as any)?.data || {}
@@ -2,7 +2,6 @@ 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 { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatNumber } from '@/lib/utils'
@@ -14,7 +13,7 @@ const STATUS_COLORS: Record<string, string> = {
'过剩': 'text-gray-400',
}
export function StaffingMatchTab() {
export function StaffingMatchTab({ month }: { month: string }) {
const { data: storesData } = useQuery({
queryKey: ['ss/stores'],
queryFn: () => api.get('/smart-scheduling/stores'),
@@ -23,8 +22,8 @@ export function StaffingMatchTab() {
const [storeName, setStoreName] = useState('七里庄店')
const { data, isLoading } = useQuery({
queryKey: ['ss/staffing-match', storeName],
queryFn: () => api.get(`/smart-scheduling/staffing-match?store=${storeName}`),
queryKey: ['ss/staffing-match', storeName, month],
queryFn: () => api.get('/smart-scheduling/staffing-match', { params: { store: storeName, month } }),
enabled: !!storeName,
})
@@ -2,42 +2,31 @@ import { useState, useMemo, Fragment } 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatNumber } from '@/lib/utils'
const PAGE_SIZE = 20
export function TrafficHeatmapTab() {
export function TrafficHeatmapTab({ month }: { month: string }) {
const [storeName, setStoreName] = useState('')
const [overviewPage, setOverviewPage] = useState(1)
const [mealPage, setMealPage] = useState(1)
const [overviewSort, setOverviewSort] = useState('total_bills')
const [overviewOrder, setOverviewOrder] = useState('desc')
const [overviewFilter, setOverviewFilter] = useState('')
const [mealSort, setMealSort] = useState('total')
const [mealOrder, setMealOrder] = useState('desc')
const [mealFilter, setMealFilter] = useState('')
const { data: overview } = useQuery({
queryKey: ['ss/traffic-overview'],
queryFn: () => api.get('/smart-scheduling/traffic-overview'),
queryKey: ['ss/traffic-overview', month],
queryFn: () => api.get('/smart-scheduling/traffic-overview', { params: { month } }),
})
const { data: heatmap, isLoading } = useQuery({
queryKey: ['ss/traffic-heatmap', storeName],
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap${storeName ? `?store=${storeName}` : ''}`),
queryKey: ['ss/traffic-heatmap', storeName, month],
queryFn: () => api.get('/smart-scheduling/traffic-heatmap', { params: { store: storeName || undefined, month } }),
})
const { data: staffing } = useQuery({
queryKey: ['ss/traffic-heatmap-staffing', storeName],
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap-staffing${storeName ? `?store=${storeName}` : ''}`),
queryKey: ['ss/traffic-heatmap-staffing', storeName, month],
queryFn: () => api.get('/smart-scheduling/traffic-heatmap-staffing', { params: { store: storeName || undefined, month } }),
})
const { data: mealPeriod } = useQuery({
queryKey: ['ss/meal-period-traffic', storeName],
queryFn: () => api.get(`/smart-scheduling/meal-period-traffic${storeName ? `?store=${storeName}` : ''}`),
queryKey: ['ss/meal-period-traffic', storeName, month],
queryFn: () => api.get('/smart-scheduling/meal-period-traffic', { params: { store: storeName || undefined, month } }),
})
const stores = (overview as any)?.data || []
@@ -80,29 +69,8 @@ export function TrafficHeatmapTab() {
mealMap[r.store_name][r.meal_period] = r.bills
})
const overviewSorts = [
{ key: 'total_bills', label: '月账单量' },
{ key: 'peak_bills', label: '峰值' },
{ key: 'peak_valley_ratio', label: '峰谷比' },
{ key: 'peak_concentration_pct', label: '集中度' },
]
const overviewFiltered = useMemo(() => {
let r = stores
if (overviewFilter) r = r.filter((s: any) => s.store_name.includes(overviewFilter))
r = [...r].sort((a: any, b: any) => {
const av = parseFloat(a[overviewSort]) || 0
const bv = parseFloat(b[overviewSort]) || 0
return overviewOrder === 'desc' ? bv - av : av - bv
})
return r
}, [stores, overviewFilter, overviewSort, overviewOrder])
const overviewTotal = overviewFiltered.length
const overviewPaged = overviewFiltered.slice((overviewPage - 1) * PAGE_SIZE, overviewPage * PAGE_SIZE)
const mealStores = useMemo(() => {
let r = Object.keys(mealMap).map(s => ({
return Object.keys(mealMap).map(s => ({
store_name: s,
早市: mealMap[s]?.['早市'] || 0,
午市: mealMap[s]?.['午市'] || 0,
@@ -111,25 +79,7 @@ export function TrafficHeatmapTab() {
夜宵: mealMap[s]?.['夜宵'] || 0,
total: Object.values(mealMap[s] || {}).reduce((a: number, b: number) => a + b, 0),
}))
if (mealFilter) r = r.filter((s: any) => s.store_name.includes(mealFilter))
r = [...r].sort((a: any, b: any) => {
const av = parseFloat(a[mealSort]) || 0
const bv = parseFloat(b[mealSort]) || 0
return mealOrder === 'desc' ? bv - av : av - bv
})
return r
}, [mealMap, mealFilter, mealSort, mealOrder])
const mealTotal = mealStores.length
const mealPaged = mealStores.slice((mealPage - 1) * PAGE_SIZE, mealPage * PAGE_SIZE)
const mealSorts = [
{ key: 'total', label: '总账单' },
{ key: '午市', label: '午市' },
{ key: '晚市', label: '晚市' },
{ key: '早市', label: '早市' },
{ key: '夜宵', label: '夜宵' },
]
}, [mealMap])
return (
<div className="space-y-4">
@@ -182,21 +132,18 @@ export function TrafficHeatmapTab() {
</CollapsibleSection>
<CollapsibleSection title="门店客流概览" subtitle="各门店月度账单量、峰谷比、客流集中度">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={overviewFilter} onChange={(e) => { setOverviewFilter(e.target.value); setOverviewPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{overviewSorts.map(s => (
<button key={s.key} onClick={() => { setOverviewSort(s.key); setOverviewPage(1) }} className={`px-3 py-1 rounded text-xs ${overviewSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOverviewOrder(overviewOrder === 'asc' ? 'desc' : 'asc'); setOverviewPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{overviewOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={stores}
sortOptions={[
{ key: 'total_bills', label: '月账单量' },
{ key: 'peak_bills', label: '峰值' },
{ key: 'peak_valley_ratio', label: '峰谷比' },
{ key: 'peak_concentration_pct', label: '集中度' },
]}
defaultSort="total_bills"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'total_bills', label: '月账单量', align: 'right', render: (r) => formatNumber(r.total_bills) },
@@ -206,27 +153,23 @@ export function TrafficHeatmapTab() {
{ key: 'peak_concentration_pct', label: '高峰集中度%', align: 'right', render: (r) => <span className={parseFloat(r.peak_concentration_pct) > 60 ? 'text-orange-600 font-medium' : ''}>{r.peak_concentration_pct}%</span> },
{ key: 'volatility_level', label: '波动等级', render: (r) => <span className={r.volatility_level === '波动极大' ? 'text-red-600' : r.volatility_level === '波动较大' ? 'text-orange-600' : ''}>{r.volatility_level}</span> },
]}
data={overviewPaged}
/>
<Pagination page={overviewPage} pageSize={PAGE_SIZE} total={overviewTotal} onPageChange={setOverviewPage} />
</CollapsibleSection>
<CollapsibleSection title="餐段客流分布" subtitle="各门店早市/午市/下午茶/晚市/夜宵账单占比">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={mealFilter} onChange={(e) => { setMealFilter(e.target.value); setMealPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{mealSorts.map(s => (
<button key={s.key} onClick={() => { setMealSort(s.key); setMealPage(1) }} className={`px-3 py-1 rounded text-xs ${mealSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setMealOrder(mealOrder === 'asc' ? 'desc' : 'asc'); setMealPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{mealOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={mealStores}
sortOptions={[
{ key: 'total', label: '总账单' },
{ key: '午市', label: '午市' },
{ key: '晚市', label: '晚市' },
{ key: '早市', label: '早市' },
{ key: '夜宵', label: '夜宵' },
]}
defaultSort="total"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: '早市', label: '早市', align: 'right', render: (r) => formatNumber(r['早市']) },
@@ -235,9 +178,7 @@ export function TrafficHeatmapTab() {
{ key: '晚市', label: '晚市', align: 'right', render: (r) => formatNumber(r['晚市']) },
{ key: '夜宵', label: '夜宵', align: 'right', render: (r) => formatNumber(r['夜宵']) },
]}
data={mealPaged}
/>
<Pagination page={mealPage} pageSize={PAGE_SIZE} total={mealTotal} onPageChange={setMealPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
@@ -21,7 +20,7 @@ export function BreakEvenTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('safety_margin_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('asc')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const { data, isLoading } = useQuery({ queryKey: ['se/break-even', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/break-even?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载盈亏平衡分析..." />
@@ -46,20 +45,23 @@ export function BreakEvenTab({ month }: { month: string }) {
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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="safety_margin_pct"
defaultOrder="asc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -72,9 +74,7 @@ export function BreakEvenTab({ month }: { month: string }) {
{ 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>
)
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function ContributionTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('asc')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const { data, isLoading } = useQuery({ queryKey: ['se/contribution', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-contribution?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载门店贡献利润..." />
@@ -39,20 +38,23 @@ export function ContributionTab({ month }: { month: string }) {
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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="actual_store_contribution_rate_pct"
defaultOrder="asc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -65,9 +67,7 @@ export function ContributionTab({ month }: { month: string }) {
{ 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>
)
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function DeliveryTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('commission_to_delivery_sales_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/delivery', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/delivery-commission?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载外卖佣金分析..." />
@@ -38,20 +37,23 @@ export function DeliveryTab({ month }: { month: string }) {
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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="commission_to_delivery_sales_pct"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -63,9 +65,7 @@ export function DeliveryTab({ month }: { month: string }) {
{ key: 'delivery_type', label: '外卖类型' },
{ key: 'suggestion', label: '建议' },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function EfficiencyTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('received_per_sqm')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/efficiency', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/efficiency?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载人效坪效数据..." />
@@ -38,20 +37,23 @@ export function EfficiencyTab({ month }: { month: string }) {
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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="received_per_sqm"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -65,9 +67,7 @@ export function EfficiencyTab({ month }: { month: string }) {
{ 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>
)
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function FixedVariableTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('fixed_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/fixed-variable', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/fixed-variable?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载固定/变动费用拆分..." />
@@ -38,20 +37,23 @@ export function FixedVariableTab({ month }: { month: string }) {
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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="fixed_rate_pct"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -62,9 +64,7 @@ export function FixedVariableTab({ month }: { month: string }) {
{ 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>
)
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -189,7 +188,7 @@ export function LossDiagnosisTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('actual_store_contribution')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('asc')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const [selectedStore, setSelectedStore] = useState<StoreDetail | null>(null)
const { data, isLoading } = useQuery({ queryKey: ['se/loss-diagnosis', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/loss-diagnosis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
@@ -212,32 +211,36 @@ export function LossDiagnosisTab({ month }: { month: string }) {
</div>
<CollapsibleSection title="亏损门店诊断" subtitle="点击门店名称查看详情">
<div className="flex gap-2 mb-3 flex-wrap">
{[
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={[
{ 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>
{[
]}
sortOptions={[
{ 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
]}
defaultSort="actual_store_contribution"
defaultOrder="asc"
onRowClick={(r) => setSelectedStore(r as StoreDetail)}
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> },
@@ -251,9 +254,7 @@ export function LossDiagnosisTab({ month }: { month: string }) {
{ 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)} />
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -22,7 +21,7 @@ export function RentRiskTab({ month }: { month: string }) {
const [sort, setSort] = useState('rent_rate_pct')
const [filter, setFilter] = useState('')
const [riskOnly, setRiskOnly] = useState(false)
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/rent', month, 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}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载房租及租约风险..." />
@@ -49,23 +48,27 @@ export function RentRiskTab({ month }: { month: string }) {
<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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="rent_rate_pct"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -77,9 +80,7 @@ export function RentRiskTab({ month }: { month: string }) {
{ 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>
)
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -111,7 +110,7 @@ export function StoreEvaluationTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('asc')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const [selectedStore, setSelectedStore] = useState<EvalStoreDetail | null>(null)
const { data, isLoading } = useQuery({ queryKey: ['se/evaluation', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-evaluation?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
@@ -131,31 +130,35 @@ export function StoreEvaluationTab({ month }: { month: string }) {
</div>
<CollapsibleSection title="门店关停/续租/改造评估" subtitle="点击门店名称查看详情">
<div className="flex gap-2 mb-3 flex-wrap">
{[
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={[
{ 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>
{[
]}
sortOptions={[
{ 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
]}
defaultSort="actual_store_contribution_rate_pct"
defaultOrder="asc"
onRowClick={(r) => setSelectedStore(r as EvalStoreDetail)}
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> },
@@ -167,9 +170,7 @@ export function StoreEvaluationTab({ month }: { month: string }) {
{ 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)} />
@@ -2,8 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function StoreRankingTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('operating_expense_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/ranking', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
@@ -41,20 +40,23 @@ export function StoreRankingTab({ month }: { month: string }) {
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
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="operating_expense_rate_pct"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -67,9 +69,7 @@ export function StoreRankingTab({ month }: { month: string }) {
{ 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>
)
+131 -116
View File
@@ -7,6 +7,7 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { Landmark, Settings2, RotateCcw } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
// ============ 评分规则类型 ============
interface ScoringRule {
@@ -83,12 +84,13 @@ function getGrade(totalScore: number): { grade: string; color: string; label: st
export function BankPage() {
const [config, setConfig] = useState<ScoringConfig>(DEFAULT_CONFIG)
const [showConfig, setShowConfig] = useState(false)
const [month, setMonth] = useState('2026-04')
useEffect(() => { setConfig(loadConfig()) }, [])
const { data: reportData, isLoading } = useQuery({
queryKey: ['bank/report'],
queryFn: () => api.get('/bank/report'),
queryKey: ['bank/report', month],
queryFn: () => api.get('/bank/report', { params: { month } }),
})
const d = (reportData as any)?.data
@@ -101,8 +103,9 @@ export function BankPage() {
const stability = d?.stability
// 计算各项指标值
const hasData = ov && wf && stability && ov.received > 0 && daily.length > 0
const metrics = useMemo(() => {
if (!ov || !wf || !stability) return null
if (!hasData) return null
const netMargin = wf.received > 0 ? (wf.store_contribution / wf.received * 100) : 0
const cv = stability.cv
const redStores = risk.filter((r: any) => r.risk_level === '红色').reduce((s: number, r: any) => s + r.store_count, 0)
@@ -181,11 +184,14 @@ export function BankPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold"><Landmark size={20} /> </h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · 20264</p>
<p className="mt-0.5 text-xs text-muted-foreground"> · </p>
</div>
<div className="flex items-center gap-2">
<MonthPicker month={month} onChange={setMonth} />
<button onClick={() => setShowConfig(!showConfig)} className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs hover:bg-accent">
<Settings2 size={14} />
</button>
</div>
<button onClick={() => setShowConfig(!showConfig)} className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs hover:bg-accent">
<Settings2 size={14} />
</button>
</div>
{/* 评分规则配置面板 */}
@@ -228,114 +234,9 @@ export function BankPage() {
</CollapsibleSection>
)}
{/* ① 企业经营概况 */}
<CollapsibleSection title="企业经营概况" subtitle="月度核心指标与日度营收趋势">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="月度实收" value={ov?.received} format="currency" description={`${formatNumber(ov?.bill_count)} 笔账单`} />
<MetricCard title="客单价" value={ov?.avg_bill_value} format="currency" description="实收 ÷ 账单数" />
<MetricCard title="理论毛利率" value={ov?.theoretical_margin_pct} format="percent" description="菜品定价毛利空间" />
<MetricCard title="门店数量" value={stores.length} unit="家" description={`活跃经营门店总数`} />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={220}>
<LineChart data={dailyChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '日实收']} />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="日实收" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</CollapsibleSection>
{/* ② 盈利能力分析 */}
<CollapsibleSection title="盈利能力分析" subtitle="利润瀑布 · 净利率 · 成本费用结构">
{wf && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="门店贡献利润估算" value={wf.store_contribution} format="currency" status={wf.store_contribution > 0 ? 'good' : 'bad'} description={`贡献率 ${wf.received > 0 ? formatPercent(wf.store_contribution / wf.received * 100) : '-'} · 仅费用已匹配门店`} />
<MetricCard title="食材成本率" value={wf.received > 0 ? wf.food_cost / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.food_cost / wf.received * 100 > 35 ? 'warn' : 'good'} description="食材成本 ÷ 实收" />
<MetricCard title="费用率" value={wf.received > 0 ? wf.total_expense / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.total_expense / wf.received * 100 > 45 ? 'warn' : 'good'} description="经营费用 ÷ 实收" />
<MetricCard title="实收分配" value={wf.received > 0 ? wf.store_contribution / wf.received * 100 : 0} format="percent" description="门店贡献利润占实收比例" />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="middle" height={60} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} shape={(props: any) => {
const { x, y, width, height, payload, fill } = props
if (!payload.value || payload.value === 0) return <rect />
const scale = height / payload.value
const newY = y - payload.base * scale
return <rect x={x} y={newY} width={width} height={height} fill={fill} rx={4} ry={4} />
}}>
{waterfallChart.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
</CollapsibleSection>
{/* ③ 门店资产质量 */}
<CollapsibleSection title="门店资产质量" subtitle="风险等级分布 · 营收TOP10">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={riskPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}`}>
{riskPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [`${v}`, name]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</PieChart>
</ResponsiveContainer>
</div>
<div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top10} layout="vertical" margin={{ top: 5, right: 10, left: 80, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="store_name" tick={{ fontSize: 9 }} width={80} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
<Bar dataKey="received" fill="#3b82f6" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</CollapsibleSection>
{/* ④ 经营稳定性指标 */}
<CollapsibleSection title="经营稳定性指标" subtitle="营收波动率 · 优惠率 · 会员占比 · 渠道结构">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="日度营收波动率" value={stability ? stability.cv * 100 : 0} format="percent" status={stability && stability.cv < 0.2 ? 'good' : stability && stability.cv < 0.3 ? 'warn' : 'bad'} description={`变异系数CV · 日均${formatCurrency(stability?.mean_daily_revenue)}`} />
<MetricCard title="优惠率" value={ov?.discount_rate_pct} format="percent" status={Number(ov?.discount_rate_pct) > 25 ? 'bad' : Number(ov?.discount_rate_pct) > 18 ? 'warn' : 'good'} description="优惠额 ÷ (实收+优惠额)" />
<MetricCard title="会员消费占比" value={ov?.member_share_pct} format="percent" status={Number(ov?.member_share_pct) > 15 ? 'good' : 'warn'} description="会员账单 ÷ 总账单" />
<MetricCard title="活跃渠道数" value={channelPie.length} unit="个" description="有交易记录的支付渠道" />
</div>
{channelPie.length > 0 && (
<div className="mt-4">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={(e: any) => `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}>
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), CHANNEL_LABELS[name] || name]} />
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v: any) => CHANNEL_LABELS[v] || v} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</CollapsibleSection>
{/* ⑤ 银行授信评估摘要 */}
{/* ① 银行授信评估摘要 */}
<CollapsibleSection title="银行授信评估摘要" subtitle="基于可配置评分规则的信用等级评估">
{scoring && (
{scoring ? (
<>
{/* 信用等级总览 */}
<div className="flex items-center gap-6 rounded-lg border p-4" style={{ borderColor: scoring.grade.color + '40' }}>
@@ -367,7 +268,7 @@ export function BankPage() {
{scoring.items.map(item => (
<tr key={item.key} className="border-b">
<td className="py-2 pr-4 font-medium">{item.label}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.value) : `${item.value.toFixed(2)}${item.unit}`}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.value) : `${Number(item.value || 0).toFixed(2)}${item.unit}`}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.rule.excellent) : `${item.rule.excellent}${item.unit}`}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.rule.pass) : `${item.rule.pass}${item.unit}`}</td>
<td className="py-2 pr-4">{item.rule.weight}%</td>
@@ -387,7 +288,7 @@ export function BankPage() {
{scoring.items.filter(i => i.score < 60).map(i => (
<div key={i.key} className="flex items-center gap-2 rounded border border-yellow-200 bg-yellow-50/40 p-2 text-xs">
<span className="text-yellow-600"></span>
<span><strong>{i.label}</strong> {i.score} {i.unit === '元' ? formatCurrency(i.value) : `${i.value.toFixed(2)}${i.unit}`}{i.rule.higherIsBetter ? `建议提升至 ${i.unit === '元' ? formatCurrency(i.rule.excellent) : i.rule.excellent + i.unit} 以上` : `建议控制在 ${i.rule.excellent}${i.unit} 以内`}</span>
<span><strong>{i.label}</strong> {i.score} {i.unit === '元' ? formatCurrency(i.value) : `${Number(i.value || 0).toFixed(2)}${i.unit}`}{i.rule.higherIsBetter ? `建议提升至 ${i.unit === '元' ? formatCurrency(i.rule.excellent) : i.rule.excellent + i.unit} 以上` : `建议控制在 ${i.rule.excellent}${i.unit} 以内`}</span>
</div>
))}
{scoring.items.filter(i => i.score < 60).length === 0 && (
@@ -398,6 +299,120 @@ export function BankPage() {
)}
</div>
</>
) : (
<div className="flex items-center gap-3 rounded-lg border border-muted bg-muted/30 p-6 text-sm text-muted-foreground">
<span className="text-2xl">📋</span>
<div>
<p className="font-medium"></p>
<p className="mt-1 text-xs"></p>
</div>
</div>
)}
</CollapsibleSection>
{/* ② 企业经营概况 */}
<CollapsibleSection title="企业经营概况" subtitle="月度核心指标与日度营收趋势">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="月度实收" value={ov?.received} format="currency" description={`${formatNumber(ov?.bill_count)} 笔账单`} />
<MetricCard title="客单价" value={ov?.avg_bill_value} format="currency" description="实收 ÷ 账单数" />
<MetricCard title="理论毛利率" value={ov?.theoretical_margin_pct} format="percent" description="菜品定价毛利空间" />
<MetricCard title="门店数量" value={stores.length} unit="家" description={`活跃经营门店总数`} />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={220}>
<LineChart data={dailyChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '日实收']} />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="日实收" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</CollapsibleSection>
{/* ③ 盈利能力分析 */}
<CollapsibleSection title="盈利能力分析" subtitle="利润瀑布 · 净利率 · 成本费用结构">
{wf && wf.received > 0 && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="门店贡献利润估算" value={wf.store_contribution} format="currency" status={wf.store_contribution > 0 ? 'good' : 'bad'} description={`贡献率 ${wf.received > 0 ? formatPercent(wf.store_contribution / wf.received * 100) : '-'} · 仅费用已匹配门店`} />
<MetricCard title="食材成本率" value={wf.received > 0 ? wf.food_cost / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.food_cost / wf.received * 100 > 35 ? 'warn' : 'good'} description="食材成本 ÷ 实收" />
<MetricCard title="费用率" value={wf.received > 0 ? wf.total_expense / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.total_expense / wf.received * 100 > 45 ? 'warn' : 'good'} description="经营费用 ÷ 实收" />
<MetricCard title="实收分配" value={wf.received > 0 ? wf.store_contribution / wf.received * 100 : 0} format="percent" description="门店贡献利润占实收比例" />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="middle" height={60} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} shape={(props: any) => {
const { x, y, width, height, payload, fill } = props
if (!payload.value || payload.value === 0) return <rect />
const absHeight = Math.abs(height)
const scale = absHeight / Math.abs(payload.value)
const newY = y - payload.base * scale
return <rect x={x} y={newY} width={width} height={absHeight} fill={fill} rx={4} ry={4} />
}}>
{waterfallChart.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
</CollapsibleSection>
{/* ④ 门店资产质量 */}
<CollapsibleSection title="门店资产质量" subtitle="风险等级分布 · 营收TOP10">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={riskPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}`}>
{riskPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [`${v}`, name]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</PieChart>
</ResponsiveContainer>
</div>
<div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top10} layout="vertical" margin={{ top: 5, right: 10, left: 80, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="store_name" tick={{ fontSize: 9 }} width={80} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
<Bar dataKey="received" fill="#3b82f6" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</CollapsibleSection>
{/* ⑤ 经营稳定性指标 */}
<CollapsibleSection title="经营稳定性指标" subtitle="营收波动率 · 优惠率 · 会员占比 · 渠道结构">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="日度营收波动率" value={stability ? stability.cv * 100 : 0} format="percent" status={stability && stability.cv < 0.2 ? 'good' : stability && stability.cv < 0.3 ? 'warn' : 'bad'} description={`变异系数CV · 日均${formatCurrency(stability?.mean_daily_revenue)}`} />
<MetricCard title="优惠率" value={ov?.discount_rate_pct} format="percent" status={Number(ov?.discount_rate_pct) > 25 ? 'bad' : Number(ov?.discount_rate_pct) > 18 ? 'warn' : 'good'} description="优惠额 ÷ (实收+优惠额)" />
<MetricCard title="会员消费占比" value={ov?.member_share_pct} format="percent" status={Number(ov?.member_share_pct) > 15 ? 'good' : 'warn'} description="会员账单 ÷ 总账单" />
<MetricCard title="活跃渠道数" value={channelPie.length} unit="个" description="有交易记录的支付渠道" />
</div>
{channelPie.length > 0 && (
<div className="mt-4">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={(e: any) => `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}>
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), CHANNEL_LABELS[name] || name]} />
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v: any) => CHANNEL_LABELS[v] || v} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</CollapsibleSection>
</div>
+288 -204
View File
@@ -1,12 +1,13 @@
import { useState } from 'react'
import { useState, useMemo, useCallback, useEffect, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Treemap } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency } from '@/lib/utils'
import { Network, ChevronRight, Layers, GitBranch } from 'lucide-react'
import { FilterableTable } from '@/components/FilterableTable'
import { formatNumber, formatCurrency, cn } from '@/lib/utils'
import { Network, ChevronRight, ChevronDown, Layers, GitBranch, Box, Package, Loader2, X } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
function varianceColorClass(pct: number): string {
@@ -20,10 +21,53 @@ function varianceText(pct: number): string {
return `${pct.toFixed(2)}%`
}
interface BomTreeNode {
level: number
material_code: string
material_name: string
unit: string
theoretical_qty: number
issue_qty: number
theoretical_amt: number
issue_amt: number
avg_unit_price: number
is_finished_product: boolean
parent_material_code: string
root_product_code: string
root_product_name: string
root_recipe: string
path: string
children: BomTreeNode[]
}
function buildTree(flatTree: any[]): BomTreeNode[] {
const nodeMap = new Map<string, BomTreeNode>()
const roots: BomTreeNode[] = []
flatTree.forEach((n: any) => {
const node: BomTreeNode = { ...n, children: [] }
nodeMap.set(node.material_code + '_' + node.level, node)
})
flatTree.forEach((n: any) => {
const key = n.material_code + '_' + n.level
const node = nodeMap.get(key)!
if (n.level === 1) {
roots.push(node)
} else {
const parentKey = n.parent_material_code + '_' + (n.level - 1)
const parent = nodeMap.get(parentKey)
if (parent) parent.children.push(node)
else roots.push(node)
}
})
return roots
}
export function BomPenetrationPage() {
const [month, setMonth] = useState('2026-04')
const [selectedProduct, setSelectedProduct] = useState('0202028')
const [productFilter, setProductFilter] = useState('')
const [selectedProduct, setSelectedProduct] = useState<string | null>(null)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const [lazyChildren, setLazyChildren] = useState<Map<string, BomTreeNode[]>>(new Map())
const [loadingNodes, setLoadingNodes] = useState<Set<string>>(new Set())
const { data: overviewData, isLoading: overviewLoading } = useQuery({
queryKey: ['bom-penetration-overview', month],
@@ -42,11 +86,69 @@ export function BomPenetrationPage() {
enabled: !!selectedProduct,
})
const bomTree = productData?.bomTree || []
// BOM树构建
const bomTreeData = useMemo(() => buildTree(bomTree), [bomTree])
const maxLevel = useMemo(() => Math.max(0, ...bomTree.map((n: any) => n.level)), [bomTree])
const toggleNode = useCallback(async (code: string, isFinishedProduct: boolean) => {
if (expandedNodes.has(code)) {
setExpandedNodes(prev => { const n = new Set(prev); n.delete(code); return n })
return
}
setExpandedNodes(prev => new Set(prev).add(code))
if (isFinishedProduct && !lazyChildren.has(code)) {
setLoadingNodes(prev => new Set(prev).add(code))
try {
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}&productCode=${code}`)
const children = buildTree(res.data.bomTree || [])
setLazyChildren(prev => new Map(prev).set(code, children))
} catch (e) {
setLazyChildren(prev => new Map(prev).set(code, []))
} finally {
setLoadingNodes(prev => { const n = new Set(prev); n.delete(code); return n })
}
}
}, [expandedNodes, lazyChildren, month])
const openProductModal = (code: string) => {
setSelectedProduct(code)
setExpandedNodes(new Set())
setLazyChildren(new Map())
setLoadingNodes(new Set())
}
// 数据加载后自动展开所有有子级的节点
useEffect(() => {
if (!bomTreeData.length) return
const toExpand = new Set<string>()
const collect = (nodes: BomTreeNode[]) => {
nodes.forEach(n => {
if (n.children.length > 0) {
toExpand.add(n.material_code)
collect(n.children)
}
})
}
collect(bomTreeData)
if (toExpand.size > 0) {
setExpandedNodes(prev => new Set([...prev, ...toExpand]))
}
}, [bomTreeData])
const closeModal = () => {
setSelectedProduct(null)
setExpandedNodes(new Set())
setLazyChildren(new Map())
setLoadingNodes(new Set())
}
if (overviewLoading) return <LoadingSpinner />
if (!overviewData) return <div className="p-4 text-muted-foreground"></div>
const { productList, bomSummary, multiLevelChains } = overviewData
const bomTree = productData?.bomTree || []
// 汇总指标
const totalProducts = productList.length
@@ -63,13 +165,63 @@ export function BomPenetrationPage() {
领用成本: b.bom_issue_amt,
}))
// BOM树按层级分组
const treeByLevel: Record<number, any[]> = {}
bomTree.forEach((node: any) => {
if (!treeByLevel[node.level]) treeByLevel[node.level] = []
treeByLevel[node.level].push(node)
})
const maxLevel = Math.max(...Object.keys(treeByLevel).map(Number), 1)
const renderTreeRows = (nodes: BomTreeNode[], depth: number): ReactNode[] => {
const rows: ReactNode[] = []
nodes.forEach((node) => {
const isExpanded = expandedNodes.has(node.material_code)
const hasChildren = node.children.length > 0
const lazyLoaded = lazyChildren.get(node.material_code)
const isLoading = loadingNodes.has(node.material_code)
const showChevron = hasChildren || node.is_finished_product
const childrenToRender = hasChildren ? node.children : (lazyLoaded || [])
const isZero = node.theoretical_qty === 0 && node.issue_qty === 0 && node.theoretical_amt === 0 && node.issue_amt === 0
rows.push(
<tr key={node.material_code + '_' + node.level} className={cn('border-b hover:bg-muted/50', node.is_finished_product && 'bg-purple-50/30', isZero && 'bg-yellow-50/40')}>
<td className="py-1.5 pr-3" style={{ paddingLeft: `${depth * 20 + 8}px` }}>
<div className="flex items-center gap-1.5">
{showChevron ? (
<button onClick={() => toggleNode(node.material_code, node.is_finished_product)} className="flex-shrink-0">
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> :
isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : (
<span className="inline-block w-3.5" />
)}
{node.is_finished_product && <Box className="h-3.5 w-3.5 text-purple-600 flex-shrink-0" />}
<span className={cn('text-xs', node.is_finished_product && 'font-medium text-purple-700')}>{node.material_name}</span>
{isZero && <span className="rounded bg-yellow-100 px-1 py-0.5 text-[10px] text-yellow-700">0</span>}
</div>
</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{node.material_code}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{node.unit}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatNumber(node.theoretical_qty)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatNumber(node.issue_qty)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatCurrency(node.theoretical_amt)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatCurrency(node.issue_amt)}</td>
<td className="py-1.5 pr-3 text-right text-xs">
<span className={cn('font-medium', node.issue_amt - node.theoretical_amt >= 0 ? 'text-yellow-600' : 'text-red-600')}>
{formatCurrency(node.issue_amt - node.theoretical_amt)}
</span>
</td>
<td className="py-1.5 pr-3" />
</tr>
)
if (isExpanded && childrenToRender.length > 0) {
rows.push(...renderTreeRows(childrenToRender, depth + 1))
}
if (isExpanded && !hasChildren && lazyLoaded && lazyLoaded.length === 0 && !isLoading) {
rows.push(
<tr key={node.material_code + '_empty'} className="border-b">
<td className="py-1 pr-3 text-xs text-muted-foreground" style={{ paddingLeft: `${(depth + 1) * 20 + 8}px` }}>
</td>
<td colSpan={8} />
</tr>
)
}
})
return rows
}
// Treemap数据
const treemapData = bomSummary.slice(0, 30).map((b: any) => ({
@@ -78,18 +230,14 @@ export function BomPenetrationPage() {
variance: b.variance_pct,
}))
const filteredProducts = productList.filter((p: any) =>
!productFilter ||
p.product_code?.includes(productFilter) ||
p.product_name?.includes(productFilter)
)
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<Network className="h-6 w-6 text-purple-600" />
<h1 className="text-xl font-bold">BOM成本穿透</h1>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Network className="h-6 w-6 text-purple-600" />
<h1 className="text-xl font-bold">BOM成本穿透</h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -115,45 +263,32 @@ export function BomPenetrationPage() {
<Bar dataKey="领用成本" fill="#722ed1" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{bomSummaryTop10.map((b: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50 cursor-pointer" onClick={() => setSelectedProduct(b.product_code)}>
<td className="py-1.5 pr-3 text-xs">{b.product_code}</td>
<td className="py-1.5 pr-3 font-medium text-blue-600">{b.product_name}</td>
<td className="py-1.5 pr-3 text-right">{b.material_count}</td>
<td className="py-1.5 pr-3 text-right">
{b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-'}
</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_theoretical_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_issue_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_theoretical_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_issue_cost)}</td>
<td className={`py-1.5 pr-3 text-right ${b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(b.variance_amt)}
</td>
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColorClass(b.variance_pct)}`}>
{varianceText(b.variance_pct)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<FilterableTable
data={bomSummary}
searchKeys={['product_code', 'product_name']}
searchPlaceholder="搜索产品..."
sortOptions={[
{ key: 'bom_theoretical_amt', label: '理论成本' },
{ key: 'bom_issue_amt', label: '领用成本' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
{ key: 'material_count', label: '材料数' },
]}
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称', render: (b) => <span className="font-medium text-blue-600">{b.product_name}</span> },
{ key: 'material_count', label: '材料数', align: 'right' },
{ key: 'sub_product_count', label: '半成品数', align: 'right', render: (b) => b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-' },
{ key: 'bom_theoretical_amt', label: '理论成本', align: 'right', render: (b) => formatCurrency(b.bom_theoretical_amt) },
{ key: 'bom_issue_amt', label: '领用成本', align: 'right', render: (b) => formatCurrency(b.bom_issue_amt) },
{ key: 'bom_unit_theoretical_cost', label: '单位理论成本', align: 'right', render: (b) => formatNumber(b.bom_unit_theoretical_cost) },
{ key: 'bom_unit_issue_cost', label: '单位领用成本', align: 'right', render: (b) => formatNumber(b.bom_unit_issue_cost) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (b) => <span className={b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(b.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (b) => <span className={`font-medium ${varianceColorClass(b.variance_pct)}`}>{varianceText(b.variance_pct)}</span> },
]}
onRowClick={(b) => setSelectedProduct(b.product_code)}
/>
</div>
</CollapsibleSection>
@@ -170,156 +305,105 @@ export function BomPenetrationPage() {
</ResponsiveContainer>
</CollapsibleSection>
{/* BOM树穿透 */}
<CollapsibleSection
title={`BOM树穿透 - ${productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}`}
subtitle={`${bomTree.length}个节点,最大${maxLevel}`}
>
<div className="mb-3 flex items-center gap-2">
<GitBranch className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground">BOM树</span>
</div>
{productLoading ? (
<LoadingSpinner />
) : (
<div className="space-y-3">
{Object.entries(treeByLevel).map(([level, nodes]) => (
<div key={level} className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<Layers className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium">Level {level}</span>
<span className="text-xs text-muted-foreground">({nodes.length})</span>
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
{nodes.map((node: any, i: number) => (
<div
key={i}
className={`rounded border p-2 ${node.is_finished_product ? 'border-purple-300 bg-purple-50/50' : 'border-gray-200'}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{node.material_name}</span>
{node.is_finished_product && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-600"></span>
)}
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>: {node.material_code}</span>
<span>: {node.unit}</span>
</div>
<div className="mt-1 flex justify-between text-xs">
<span>: {formatCurrency(node.theoretical_amt)}</span>
<span>: {formatCurrency(node.issue_amt)}</span>
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>: {formatNumber(node.theoretical_qty)}</span>
<span>: {formatNumber(node.issue_qty)}</span>
</div>
{node.is_finished_product && (
<button
className="mt-1 text-xs text-purple-600 hover:underline"
onClick={() => setSelectedProduct(node.material_code)}
>
BOM
</button>
)}
</div>
))}
</div>
</div>
))}
{bomTree.length === 0 && (
<div className="py-4 text-center text-muted-foreground"></div>
)}
</div>
)}
</CollapsibleSection>
{/* 产品选择器 */}
<CollapsibleSection title="产品列表" subtitle={`${filteredProducts.length}个产品`} defaultOpen={false}
headerRight={
<input
type="text"
placeholder="搜索产品..."
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
className="rounded border px-3 py-1 text-sm"
/>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-center">BOM</th>
<th className="pb-2 pr-3"></th>
</tr>
</thead>
<tbody>
{filteredProducts.map((p: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{p.product_code}</td>
<td className="py-1.5 pr-3 font-medium">{p.product_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{p.category_minor}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(p.inbound_quantity)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.theoretical_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.actual_cost)}</td>
<td className="py-1.5 pr-3 text-right">{p.material_count}</td>
<td className="py-1.5 pr-3 text-center">
{p.has_multi_level_bom ? <span className="text-purple-600"></span> : '-'}
</td>
<td className="py-1.5 pr-3">
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => setSelectedProduct(p.product_code)}
>
BOM
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<CollapsibleSection title="产品列表" subtitle={`${productList.length}个产品`}>
<FilterableTable
data={productList}
searchKeys={['product_code', 'product_name']}
searchPlaceholder="搜索产品..."
sortOptions={[
{ key: 'theoretical_cost', label: '理论成本' },
{ key: 'actual_cost', label: '实际成本' },
{ key: 'inbound_quantity', label: '入库量' },
{ key: 'material_count', label: '材料数' },
]}
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称', render: (p) => <span className="font-medium">{p.product_name}</span> },
{ key: 'category_minor', label: '品类' },
{ key: 'inbound_quantity', label: '入库量', align: 'right', render: (p) => formatNumber(p.inbound_quantity) },
{ key: 'theoretical_cost', label: '理论成本', align: 'right', render: (p) => formatCurrency(p.theoretical_cost) },
{ key: 'actual_cost', label: '实际成本', align: 'right', render: (p) => formatCurrency(p.actual_cost) },
{ key: 'material_count', label: '材料数', align: 'right' },
{ key: 'has_multi_level_bom', label: '多级BOM', align: 'center', render: (p) => p.has_multi_level_bom ? <span className="text-purple-600"></span> : '-' },
{ key: 'action', label: '', render: (p) => <button className="text-xs text-blue-600 hover:underline" onClick={(e) => { e.stopPropagation(); openProductModal(p.product_code) }}>BOM</button> },
]}
onRowClick={(p) => openProductModal(p.product_code)}
/>
</CollapsibleSection>
{/* 半成品依赖链 */}
<CollapsibleSection title="半成品依赖链" subtitle={`${multiLevelChains.length}条成品→半成品依赖关系`} defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
</tr>
</thead>
<tbody>
{multiLevelChains.map((c: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{c.finished_code}</td>
<td className="py-1.5 pr-3 font-medium">{c.finished_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.finished_recipe}</td>
<td className="py-1.5 pr-3"><ChevronRight className="h-4 w-4 text-purple-600" /></td>
<td className="py-1.5 pr-3 text-xs">{c.sub_product_code}</td>
<td className="py-1.5 pr-3 font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.sub_product_recipe || '-'}</td>
</tr>
))}
</tbody>
</table>
<div className="mb-3 flex items-center gap-2">
<Network className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground">BOM依赖关系</span>
</div>
<FilterableTable
data={multiLevelChains}
searchKeys={['finished_code', 'finished_name', 'sub_product_code', 'sub_product_name']}
searchPlaceholder="搜索成品/半成品..."
sortOptions={[
{ key: 'finished_name', label: '成品名称' },
{ key: 'sub_product_name', label: '半成品名称' },
]}
columns={[
{ key: 'finished_code', label: '成品编码' },
{ key: 'finished_name', label: '成品名称', render: (c) => <span className="font-medium">{c.finished_name}</span> },
{ key: 'finished_recipe', label: '成品配方' },
{ key: 'arrow', label: '', render: () => <ChevronRight className="h-4 w-4 text-purple-600" /> },
{ key: 'sub_product_code', label: '半成品编码' },
{ key: 'sub_product_finished_name', label: '半成品名称', render: (c) => <span className="font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</span> },
{ key: 'sub_product_recipe', label: '半成品配方', render: (c) => c.sub_product_recipe || '-' },
]}
/>
</CollapsibleSection>
{/* BOM树弹窗 */}
{selectedProduct && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={closeModal}>
<div className="max-h-[85vh] w-[90vw] max-w-5xl overflow-hidden rounded-lg bg-white shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<GitBranch className="h-5 w-5 text-purple-600" />
<h2 className="text-base font-semibold">
BOM树穿透 - {productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}
</h2>
<span className="text-xs text-muted-foreground">{bomTree.length}{maxLevel}</span>
</div>
<button onClick={closeModal} className="rounded p-1 hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[calc(85vh-60px)] overflow-auto p-4">
{productLoading ? (
<LoadingSpinner />
) : bomTree.length === 0 ? (
<div className="py-8 text-center text-muted-foreground"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{renderTreeRows(bomTreeData, 0)}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}
+143 -215
View File
@@ -5,6 +5,7 @@ import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { ChefHat, TrendingDown, TrendingUp, AlertTriangle, Package, Factory } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
@@ -24,7 +25,6 @@ function varianceText(pct: number): string {
export function CentralKitchenPage() {
const [month, setMonth] = useState('2026-04')
const [productFilter, setProductFilter] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['central-kitchen-dashboard', month],
@@ -39,13 +39,6 @@ export function CentralKitchenPage() {
const { summary, reconciliation, products, categoryCost, recipeEfficiency, mfgPool, yieldAnalysis } = data
const filteredProducts = products.filter((p: any) =>
!productFilter ||
p.product_name?.includes(productFilter) ||
p.product_code?.includes(productFilter) ||
p.category_minor?.includes(productFilter)
)
// 成本对账瀑布数据
const waterfallData = [
{ name: '理论成本', value: reconciliation.theoretical_cost, type: 'base' },
@@ -76,9 +69,11 @@ export function CentralKitchenPage() {
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<ChefHat className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ChefHat className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -135,37 +130,29 @@ export function CentralKitchenPage() {
<Bar dataKey="制造费用" stackId="a" fill="#faad14" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
</tr>
</thead>
<tbody>
{categoryCost.map((c: any, i: number) => (
<tr key={i} className="border-b">
<td className="py-2 pr-4 font-medium">{c.category_minor}</td>
<td className="py-2 pr-4 text-right">{c.product_count}</td>
<td className="py-2 pr-4 text-right">{formatNumber(c.total_qty)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.theoretical_cost)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.material_actual_cost)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.allocated_mfg_cost)}</td>
<td className="py-2 pr-4 text-right font-medium">{formatCurrency(c.full_cost)}</td>
<td className={`py-2 pr-4 text-right font-medium ${varianceColor(c.efficiency_var_pct) === 'good' ? 'text-green-600' : varianceColor(c.efficiency_var_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
{varianceText(c.efficiency_var_pct)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<FilterableTable
data={categoryCost}
filterKey="category_minor"
filterLabel="全部品类"
sortOptions={[
{ key: 'theoretical_cost', label: '理论成本' },
{ key: 'material_actual_cost', label: '实际材料' },
{ key: 'allocated_mfg_cost', label: '制造费用' },
{ key: 'full_cost', label: '全成本' },
{ key: 'efficiency_var_pct', label: '效率差异' },
]}
columns={[
{ key: 'category_minor', label: '品类' },
{ key: 'product_count', label: '产品数', align: 'right' },
{ key: 'total_qty', label: '入库量', align: 'right', render: (c) => formatNumber(c.total_qty) },
{ key: 'theoretical_cost', label: '理论成本', align: 'right', render: (c) => formatCurrency(c.theoretical_cost) },
{ key: 'material_actual_cost', label: '实际材料', align: 'right', render: (c) => formatCurrency(c.material_actual_cost) },
{ key: 'allocated_mfg_cost', label: '制造费用', align: 'right', render: (c) => formatCurrency(c.allocated_mfg_cost) },
{ key: 'full_cost', label: '全成本', align: 'right', render: (c) => <span className="font-medium">{formatCurrency(c.full_cost)}</span> },
{ key: 'efficiency_var_pct', label: '效率差异', align: 'right', render: (c) => <span className={`font-medium ${varianceColor(c.efficiency_var_pct) === 'good' ? 'text-green-600' : varianceColor(c.efficiency_var_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>{varianceText(c.efficiency_var_pct)}</span> },
]}
/>
</div>
</CollapsibleSection>
@@ -176,7 +163,19 @@ export function CentralKitchenPage() {
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="x" name="理论成本" tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} label={{ value: '理论成本', position: 'insideBottom', offset: -5, fontSize: 11 }} />
<YAxis type="number" dataKey="y" name="实际成本" tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} label={{ value: '实际成本', angle: -90, position: 'insideLeft', fontSize: 11 }} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, name: any) => name === 'x' || name === 'y' ? formatCurrency(v) : v} labelFormatter={() => ''} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} content={({ active, payload }: any) => {
if (!active || !payload?.length) return null
const p = payload[0].payload
return (
<div className="rounded border bg-white px-3 py-2 text-xs shadow">
<div className="font-medium">{p.name}</div>
<div>: {p.category}</div>
<div>: {formatCurrency(p.x)}</div>
<div>: {formatCurrency(p.y)}</div>
<div>: {p.z.toFixed(2)}%</div>
</div>
)
}} />
<Scatter data={scatterData}>
{scatterData.map((entry: any, idx: number) => (
<Cell key={idx} fill={
@@ -199,191 +198,120 @@ export function CentralKitchenPage() {
{/* 产品明细表 */}
<CollapsibleSection
title="产品成本明细"
subtitle={`${filteredProducts.length}个产品`}
headerRight={
<input
type="text"
placeholder="搜索产品名/编码/品类..."
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
className="rounded border px-3 py-1 text-sm"
/>
}
subtitle={`${products.length}个产品`}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{filteredProducts.map((p: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{p.product_code}</td>
<td className="py-1.5 pr-3 font-medium">{p.product_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{p.category_minor}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(p.inbound_quantity)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.theoretical_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.standard_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.material_actual_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.allocated_manufacturing_cost)}</td>
<td className="py-1.5 pr-3 text-right font-medium">{formatCurrency(p.full_manufacturing_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(p.full_unit_cost)}</td>
<td className={`py-1.5 pr-3 text-right text-xs ${varianceColor(p.efficiency_variance_pct) === 'good' ? 'text-green-600' : varianceColor(p.efficiency_variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
{varianceText(p.efficiency_variance_pct)}
</td>
<td className={`py-1.5 pr-3 text-right text-xs ${varianceColor(p.standard_variance_pct) === 'good' ? 'text-green-600' : varianceColor(p.standard_variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
{varianceText(p.standard_variance_pct)}
</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.inbound_value)}</td>
<td className={`py-1.5 pr-3 text-right ${p.product_margin >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(p.product_margin)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<FilterableTable
data={products}
searchKeys={['product_code', 'product_name', 'category_minor']}
searchPlaceholder="搜索产品名/编码/品类..."
sortOptions={[
{ key: 'theoretical_cost', label: '理论成本' },
{ key: 'standard_cost', label: '标准成本' },
{ key: 'material_actual_cost', label: '实际材料' },
{ key: 'full_manufacturing_cost', label: '全成本' },
{ key: 'efficiency_variance_pct', label: '效率差异' },
{ key: 'standard_variance_pct', label: '标准差异' },
{ key: 'product_margin', label: '产品毛利' },
{ key: 'inbound_quantity', label: '入库量' },
]}
columns={[
{ key: 'product_code', label: '编码' },
{ key: 'product_name', label: '产品名', render: (p) => <span className="font-medium">{p.product_name}</span> },
{ key: 'category_minor', label: '品类' },
{ key: 'inbound_quantity', label: '入库量', align: 'right', render: (p) => formatNumber(p.inbound_quantity) },
{ key: 'theoretical_cost', label: '理论成本', align: 'right', render: (p) => formatCurrency(p.theoretical_cost) },
{ key: 'standard_cost', label: '标准成本', align: 'right', render: (p) => formatCurrency(p.standard_cost) },
{ key: 'material_actual_cost', label: '实际材料', align: 'right', render: (p) => formatCurrency(p.material_actual_cost) },
{ key: 'allocated_manufacturing_cost', label: '制造费用', align: 'right', render: (p) => formatCurrency(p.allocated_manufacturing_cost) },
{ key: 'full_manufacturing_cost', label: '全成本', align: 'right', render: (p) => <span className="font-medium">{formatCurrency(p.full_manufacturing_cost)}</span> },
{ key: 'full_unit_cost', label: '单位成本', align: 'right', render: (p) => formatNumber(p.full_unit_cost) },
{ key: 'efficiency_variance_pct', label: '效率差异', align: 'right', render: (p) => <span className={`text-xs ${varianceColor(p.efficiency_variance_pct) === 'good' ? 'text-green-600' : varianceColor(p.efficiency_variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>{varianceText(p.efficiency_variance_pct)}</span> },
{ key: 'standard_variance_pct', label: '标准差异', align: 'right', render: (p) => <span className={`text-xs ${varianceColor(p.standard_variance_pct) === 'good' ? 'text-green-600' : varianceColor(p.standard_variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>{varianceText(p.standard_variance_pct)}</span> },
{ key: 'inbound_value', label: '入库价值', align: 'right', render: (p) => formatCurrency(p.inbound_value) },
{ key: 'product_margin', label: '产品毛利', align: 'right', render: (p) => <span className={p.product_margin >= 0 ? 'text-green-600' : 'text-red-600'}>{formatCurrency(p.product_margin)}</span> },
]}
/>
</CollapsibleSection>
{/* 配方效率分析 */}
<CollapsibleSection title="配方效率分析 TOP20" subtitle="按材料效率差异绝对值排序,识别超耗最严重的配方" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{recipeEfficiency.map((r: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 font-medium">{r.recipe_name}</td>
<td className="py-1.5 pr-3">{r.item_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{r.specification}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(r.theoretical_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(r.actual_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(r.theoretical_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(r.actual_amt)}</td>
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColor(r.variance_pct) === 'good' ? 'text-green-600' : varianceColor(r.variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
{varianceText(r.variance_pct)}
</td>
<td className="py-1.5 pr-3 text-right">{r.avg_actual_yield ? `${(r.avg_actual_yield * 100).toFixed(2)}%` : '-'}</td>
<td className="py-1.5 pr-3 text-right">{r.avg_recipe_yield ? `${(r.avg_recipe_yield * 100).toFixed(2)}%` : '-'}</td>
<td className={`py-1.5 pr-3 text-right text-xs ${r.yield_diff < -0.03 ? 'text-red-600' : r.yield_diff > 0.03 ? 'text-green-600' : ''}`}>
{r.yield_diff ? `${(r.yield_diff * 100).toFixed(2)}pct` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<FilterableTable
data={recipeEfficiency}
searchKeys={['recipe_name', 'item_name']}
searchPlaceholder="搜索配方/物料..."
sortOptions={[
{ key: 'theoretical_amt', label: '理论金额' },
{ key: 'actual_amt', label: '实际金额' },
{ key: 'variance_pct', label: '金额差异率' },
{ key: 'yield_diff', label: '出成差异' },
]}
columns={[
{ key: 'recipe_name', label: '配方', render: (r) => <span className="font-medium">{r.recipe_name}</span> },
{ key: 'item_name', label: '物料' },
{ key: 'specification', label: '规格' },
{ key: 'theoretical_qty', label: '理论用量', align: 'right', render: (r) => formatNumber(r.theoretical_qty) },
{ key: 'actual_qty', label: '实际用量', align: 'right', render: (r) => formatNumber(r.actual_qty) },
{ key: 'theoretical_amt', label: '理论金额', align: 'right', render: (r) => formatCurrency(r.theoretical_amt) },
{ key: 'actual_amt', label: '实际金额', align: 'right', render: (r) => formatCurrency(r.actual_amt) },
{ key: 'variance_pct', label: '金额差异率', align: 'right', render: (r) => <span className={`font-medium ${varianceColor(r.variance_pct) === 'good' ? 'text-green-600' : varianceColor(r.variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>{varianceText(r.variance_pct)}</span> },
{ key: 'avg_actual_yield', label: '实际出成率', align: 'right', render: (r) => r.avg_actual_yield ? `${(r.avg_actual_yield * 100).toFixed(2)}%` : '-' },
{ key: 'avg_recipe_yield', label: '标准出成率', align: 'right', render: (r) => r.avg_recipe_yield ? `${(r.avg_recipe_yield * 100).toFixed(2)}%` : '-' },
{ key: 'yield_diff', label: '出成差异', align: 'right', render: (r) => <span className={`text-xs ${r.yield_diff < -0.03 ? 'text-red-600' : r.yield_diff > 0.03 ? 'text-green-600' : ''}`}>{r.yield_diff ? `${(r.yield_diff * 100).toFixed(2)}pct` : '-'}</span> },
]}
/>
</CollapsibleSection>
{/* 出成率分析 */}
<CollapsibleSection title="出成率分析 TOP20" subtitle="按预期偏差率排序,识别产出异常产品" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right">退</th>
<th className="pb-2 pr-3 text-right">退</th>
</tr>
</thead>
<tbody>
{yieldAnalysis.map((y: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 font-medium">{y.recipe_name}</td>
<td className="py-1.5 pr-3">{y.product_name}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(y.theoretical_inbound_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(y.actual_inbound_qty)}</td>
<td className={`py-1.5 pr-3 text-right ${y.avg_achievement_rate >= 0.95 ? 'text-green-600' : y.avg_achievement_rate >= 0.9 ? 'text-yellow-600' : 'text-red-600'}`}>
{y.avg_achievement_rate ? `${(y.avg_achievement_rate * 100).toFixed(2)}%` : '-'}
</td>
<td className={`py-1.5 pr-3 text-right text-xs ${y.avg_expected_var_rate < -0.03 ? 'text-red-600' : y.avg_expected_var_rate > 0.03 ? 'text-green-600' : ''}`}>
{y.avg_expected_var_rate ? `${(y.avg_expected_var_rate * 100).toFixed(2)}%` : '-'}
</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(y.total_inbound_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(y.total_return_qty)}</td>
<td className={`py-1.5 pr-3 text-right ${y.return_rate > 1 ? 'text-red-600' : ''}`}>
{y.return_rate ? `${y.return_rate.toFixed(2)}%` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<FilterableTable
data={yieldAnalysis}
searchKeys={['recipe_name', 'product_name']}
searchPlaceholder="搜索配方/产品..."
sortOptions={[
{ key: 'theoretical_inbound_qty', label: '理论入库量' },
{ key: 'actual_inbound_qty', label: '实际入库量' },
{ key: 'avg_achievement_rate', label: '达成率' },
{ key: 'avg_expected_var_rate', label: '预期偏差率' },
{ key: 'total_inbound_amt', label: '入库金额' },
{ key: 'return_rate', label: '退库率' },
]}
columns={[
{ key: 'recipe_name', label: '配方', render: (y) => <span className="font-medium">{y.recipe_name}</span> },
{ key: 'product_name', label: '产品名' },
{ key: 'theoretical_inbound_qty', label: '理论入库量', align: 'right', render: (y) => formatNumber(y.theoretical_inbound_qty) },
{ key: 'actual_inbound_qty', label: '实际入库量', align: 'right', render: (y) => formatNumber(y.actual_inbound_qty) },
{ key: 'avg_achievement_rate', label: '达成率', align: 'right', render: (y) => <span className={y.avg_achievement_rate >= 0.95 ? 'text-green-600' : y.avg_achievement_rate >= 0.9 ? 'text-yellow-600' : 'text-red-600'}>{y.avg_achievement_rate ? `${(y.avg_achievement_rate * 100).toFixed(2)}%` : '-'}</span> },
{ key: 'avg_expected_var_rate', label: '预期偏差率', align: 'right', render: (y) => <span className={`text-xs ${y.avg_expected_var_rate < -0.03 ? 'text-red-600' : y.avg_expected_var_rate > 0.03 ? 'text-green-600' : ''}`}>{y.avg_expected_var_rate ? `${(y.avg_expected_var_rate * 100).toFixed(2)}%` : '-'}</span> },
{ key: 'total_inbound_amt', label: '入库金额', align: 'right', render: (y) => formatCurrency(y.total_inbound_amt) },
{ key: 'total_return_qty', label: '退库量', align: 'right', render: (y) => formatNumber(y.total_return_qty) },
{ key: 'return_rate', label: '退库率', align: 'right', render: (y) => <span className={y.return_rate > 1 ? 'text-red-600' : ''}>{y.return_rate ? `${y.return_rate.toFixed(2)}%` : '-'}</span> },
]}
/>
</CollapsibleSection>
{/* 制造费用池 */}
<CollapsibleSection title="制造费用池明细" subtitle="制造费用归集与分摊方法" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
</tr>
</thead>
<tbody>
{mfgPool.map((m: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 font-medium">{m.cost_type}</td>
<td className="py-1.5 pr-3">{m.cost_subtype}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{m.source_type}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(m.source_amount)}</td>
<td className="py-1.5 pr-3 text-right">{m.share_pct ? `${(m.share_pct * 100).toFixed(2)}%` : '-'}</td>
<td className="py-1.5 pr-3 text-right font-medium">{formatCurrency(m.allocated_amount)}</td>
<td className="py-1.5 pr-3 text-xs">{m.allocation_method}</td>
<td className="py-1.5 pr-3 text-center">{m.include_in_rebuilt_cost ? '✅' : '❌'}</td>
<td className="py-1.5 pr-3 text-center">
{m.is_provisional && <AlertTriangle className="inline h-4 w-4 text-yellow-500" />}
</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground max-w-xs">{m.note}</td>
</tr>
))}
</tbody>
</table>
</div>
<FilterableTable
data={mfgPool}
searchKeys={['cost_type', 'cost_subtype', 'source_type']}
searchPlaceholder="搜索费用类型/子类/来源..."
sortOptions={[
{ key: 'source_amount', label: '原始金额' },
{ key: 'allocated_amount', label: '分摊金额' },
]}
columns={[
{ key: 'cost_type', label: '费用类型', render: (m) => <span className="font-medium">{m.cost_type}</span> },
{ key: 'cost_subtype', label: '费用子类' },
{ key: 'source_type', label: '来源' },
{ key: 'source_amount', label: '原始金额', align: 'right', render: (m) => formatCurrency(m.source_amount) },
{ key: 'share_pct', label: '分摊比例', align: 'right', render: (m) => m.share_pct ? `${(m.share_pct * 100).toFixed(2)}%` : '-' },
{ key: 'allocated_amount', label: '分摊金额', align: 'right', render: (m) => <span className="font-medium">{formatCurrency(m.allocated_amount)}</span> },
{ key: 'allocation_method', label: '分摊方法' },
{ key: 'include_in_rebuilt_cost', label: '计入重建', align: 'center', render: (m) => m.include_in_rebuilt_cost ? '✅' : '❌' },
{ key: 'is_provisional', label: '临时', align: 'center', render: (m) => m.is_provisional ? <AlertTriangle className="inline h-4 w-4 text-yellow-500" /> : '' },
{ key: 'note', label: '备注' },
]}
/>
<div className="mt-3 flex items-center gap-2 rounded-md bg-yellow-50 p-3 text-xs text-yellow-800">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span></span>
+18 -13
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Tabs } from '@/components/Tabs'
import { MonthPicker } from '@/components/MonthPicker'
import { OverviewTab } from '@/components/cost-analysis/OverviewTab'
import { ProfitabilityTab } from '@/components/cost-analysis/ProfitabilityTab'
import { MaterialTab } from '@/components/cost-analysis/MaterialTab'
@@ -26,25 +27,29 @@ const TABS = [
export function CostAnalysisPage() {
const [activeTab, setActiveTab] = useState('overview')
const [month, setMonth] = useState('2026-04')
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">BOM与成本报表的多维度运营分析</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">BOM与成本报表的多维度运营分析</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div className="mt-4">
{activeTab === 'overview' && <OverviewTab />}
{activeTab === 'profitability' && <ProfitabilityTab />}
{activeTab === 'material' && <MaterialTab />}
{activeTab === 'bom' && <BomTab />}
{activeTab === 'supply' && <SupplyTab />}
{activeTab === 'packaging' && <PackagingTab />}
{activeTab === 'quality' && <QualityTab />}
{activeTab === 'store' && <StoreTab />}
{activeTab === 'explore' && <ExploreTab />}
{activeTab === 'adjustment' && <AdjustmentTab />}
{activeTab === 'overview' && <OverviewTab month={month} />}
{activeTab === 'profitability' && <ProfitabilityTab month={month} />}
{activeTab === 'material' && <MaterialTab month={month} />}
{activeTab === 'bom' && <BomTab month={month} />}
{activeTab === 'supply' && <SupplyTab month={month} />}
{activeTab === 'packaging' && <PackagingTab month={month} />}
{activeTab === 'quality' && <QualityTab month={month} />}
{activeTab === 'store' && <StoreTab month={month} />}
{activeTab === 'explore' && <ExploreTab month={month} />}
{activeTab === 'adjustment' && <AdjustmentTab month={month} />}
</div>
</div>
)
+110 -84
View File
@@ -1,22 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter } from 'recharts'
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ReferenceLine } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
export function CostPage() {
const [costPage, setCostPage] = useState(1)
const [invPage, setInvPage] = useState(1)
const [catPage, setCatPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: costData, isLoading: costLoading } = useQuery({
@@ -24,21 +17,26 @@ export function CostPage() {
queryFn: () => api.get('/cost/comparison', { params: { month } }),
})
const [invOpen, setInvOpen] = useState(false)
const [catOpen, setCatOpen] = useState(false)
const { data: invData, isLoading: invLoading } = useQuery({
queryKey: ['cost/inventory', month],
queryFn: () => api.get('/cost/inventory', { params: { month } }),
enabled: invOpen,
})
const { data: catData, isLoading: catLoading } = useQuery({
queryKey: ['cost/category-benchmark', month],
queryFn: () => api.get('/cost/category-benchmark', { params: { month } }),
enabled: catOpen,
})
const costRows = (costData as any)?.data || []
const invRows = (invData as any)?.data || []
const catRows = (catData as any)?.data || []
const pageLoading = costLoading || invLoading || catLoading
const pageLoading = costLoading
const validCostRows = costRows.filter((r: any) => !r.variance_level?.includes('口径异常'))
const abnormalRows = costRows.filter((r: any) => r.variance_level?.includes('口径异常'))
@@ -50,11 +48,6 @@ export function CostPage() {
? validCostRows.reduce((s: number, r: any) => s + Number(r.actual_food_cost_rate_pct || 0), 0) / validCostRows.length
: 0
const highVarianceCount = validCostRows.filter((r: any) => Number(r.variance_to_theoretical_pct || 0) > 20).length
const abnormalInvCount = invRows.filter((r: any) => Number(r.estimated_inventory_days) > 7 || Number(r.negative_item_lines) > 0).length
const pagedCost = useMemo(() => costRows.slice((costPage - 1) * PAGE_SIZE, costPage * PAGE_SIZE), [costRows, costPage])
const pagedInv = useMemo(() => invRows.slice((invPage - 1) * PAGE_SIZE, invPage * PAGE_SIZE), [invRows, invPage])
const pagedCat = useMemo(() => catRows.slice((catPage - 1) * PAGE_SIZE, catPage * PAGE_SIZE), [catRows, catPage])
const scatterData = validCostRows.map((r: any) => ({
name: r.store_name,
@@ -63,6 +56,17 @@ export function CostPage() {
variance: Number(r.variance_to_theoretical_pct || 0),
}))
const scatterDomain = useMemo(() => {
if (scatterData.length === 0) return { x: [0, 40], y: [0, 50] }
const xs = scatterData.map((d: any) => d.theoretical)
const ys = scatterData.map((d: any) => d.actual)
const xMin = Math.floor(Math.min(...xs) - 1)
const xMax = Math.ceil(Math.max(...xs) + 1)
const yMin = Math.floor(Math.min(...ys) - 1)
const yMax = Math.ceil(Math.max(...ys) + 1)
return { x: [xMin, xMax], y: [yMin, yMax] }
}, [scatterData])
if (pageLoading) {
return <LoadingSpinner text="加载成本数据..." />
}
@@ -101,8 +105,9 @@ export function CostPage() {
<ResponsiveContainer width="100%" height={300}>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="theoretical" name="理论成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 40]} />
<YAxis type="number" dataKey="actual" name="实际成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 50]} />
<XAxis type="number" dataKey="theoretical" name="理论成本率" unit="%" tick={{ fontSize: 10 }} domain={scatterDomain.x} />
<YAxis type="number" dataKey="actual" name="实际成本率" unit="%" tick={{ fontSize: 10 }} domain={scatterDomain.y} />
<ReferenceLine segment={[{ x: scatterDomain.x[0], y: scatterDomain.y[0] }, { x: scatterDomain.x[1], y: scatterDomain.y[1] }]} stroke="#94a3b8" strokeDasharray="4 4" />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ active, payload }: any) => {
@@ -127,78 +132,99 @@ export function CostPage() {
{/* 成本对比表 */}
<CollapsibleSection title={`门店成本对比 (${costRows.length})`} subtitle="理论成本 vs 实际倒挤成本,按偏差降序">
<Pagination page={costPage} pageSize={PAGE_SIZE} total={costRows.length} onPageChange={setCostPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theoretical_cost_rate_pct', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theoretical_cost_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '实际成本', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
{ key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_to_theoretical_pct || 0)
return <span className={v > 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span>
}},
{ key: 'variance_level', label: '偏差等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.variance_level === '高偏差' ? 'bg-red-100 text-red-700' :
r.variance_level === '中偏差' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>{r.variance_level || '-'}</span>
)},
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
]}
data={pagedCost}
/>
</div>
<FilterableTable
data={costRows}
sortOptions={[
{ key: 'variance_to_theoretical_pct', label: '偏差' },
{ key: 'theoretical_cost_rate_pct', label: '理论成本率' },
{ key: 'actual_food_cost_rate_pct', label: '实际成本率' },
{ key: 'actual_food_cost', label: '实际食材成本' },
{ key: 'sales_received', label: '销售额' },
]}
defaultSort="variance_to_theoretical_pct"
defaultOrder="desc"
filterKey="variance_level"
filterLabel="全部等级"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theoretical_cost_rate_pct', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theoretical_cost_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
{ key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_to_theoretical_pct || 0)
return <span className={v > 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span>
}},
{ key: 'variance_level', label: '偏差等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.variance_level === '高偏差' ? 'bg-red-100 text-red-700' :
r.variance_level === '中偏差' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>{r.variance_level || '-'}</span>
)},
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
]}
/>
</CollapsibleSection>
{/* 库存效率 */}
<CollapsibleSection title={`库存效率 (${invRows.length})`} subtitle="库存天数、负耗用、异常货品" defaultOpen={false}>
<Pagination page={invPage} pageSize={PAGE_SIZE} total={invRows.length} onPageChange={setInvPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'business_type', label: '业态' },
{ key: 'scale_tier', label: '规模' },
{ key: 'estimated_inventory_days', label: '库存天数', align: 'right', render: (r) => {
const d = Number(r.estimated_inventory_days || 0)
return <span className={d > 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}</span>
}},
{ key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) },
{ key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="font-medium text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
{ key: 'abnormal_item_lines', label: '异常货品行', align: 'center', render: (r) => {
const n = Number(r.abnormal_item_lines || 0)
return n > 0 ? <span className="font-medium text-yellow-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
]}
data={pagedInv}
/>
</div>
<CollapsibleSection title="库存效率" subtitle="库存天数、负耗用、异常货品" defaultOpen={false} onToggle={setInvOpen}>
{invLoading ? <LoadingSpinner text="加载库存数据..." /> : <>
<FilterableTable
data={invRows}
sortOptions={[
{ key: 'ending_inventory_amount', label: '期末库存' },
{ key: 'negative_item_lines', label: '负耗用行数' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="ending_inventory_amount"
defaultOrder="desc"
filterKey="business_type"
filterLabel="全部业态"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'business_type', label: '业态' },
{ key: 'scale_tier', label: '规模' },
{ key: 'estimated_inventory_days', label: '库存天数', align: 'right', render: (r) => {
const d = Number(r.estimated_inventory_days || 0)
return <span className={d > 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}</span>
}},
{ key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) },
{ key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="font-medium text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
]}
/>
</>}
</CollapsibleSection>
{/* 原料分类差异 */}
<CollapsibleSection title={`原料分类成本基准 (${catRows.length})`} subtitle="按财务分类对比同业基准" defaultOpen={false}>
<Pagination page={catPage} pageSize={PAGE_SIZE} total={catRows.length} onPageChange={setCatPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'finance_category', label: '财务分类' },
{ key: 'actual_category_cost', label: '实际成本', align: 'right', render: (r) => formatCurrency(r.actual_category_cost) },
{ key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) },
{ key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) },
{ key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => {
const v = Number(r.excess_vs_peer_per_10k || 0)
return v > 0 ? <span className="text-red-600">+{formatCurrency(v)}</span> : <span className="text-green-600">{formatCurrency(v)}</span>
}},
]}
data={pagedCat}
/>
</div>
<CollapsibleSection title="原料分类成本基准" subtitle="按财务分类对比同业基准" defaultOpen={false} onToggle={setCatOpen}>
{catLoading ? <LoadingSpinner text="加载分类数据..." /> : <>
<FilterableTable
data={catRows}
sortOptions={[
{ key: 'actual_category_cost', label: '实际成本' },
{ key: 'cost_per_10k_sales', label: '每万元成本' },
{ key: 'excess_vs_peer_per_10k', label: '超出同业' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="excess_vs_peer_per_10k"
defaultOrder="desc"
filterKey="finance_category"
filterLabel="全部分类"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'finance_category', label: '财务分类' },
{ key: 'actual_category_cost', label: '实际成本', align: 'right', render: (r) => formatCurrency(r.actual_category_cost) },
{ key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) },
{ key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) },
{ key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => {
const v = Number(r.excess_vs_peer_per_10k || 0)
return v > 0 ? <span className="text-red-600">+{formatCurrency(v)}</span> : <span className="text-green-600">{formatCurrency(v)}</span>
}},
]}
/>
</>}
</CollapsibleSection>
</div>
)
+18 -16
View File
@@ -4,21 +4,18 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MonthPicker } from '@/components/MonthPicker'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
const PAGE_SIZE = 10
export function DashboardPage() {
const navigate = useNavigate()
const [priorityPage, setPriorityPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: overview, isLoading: odLoading } = useQuery({
@@ -52,13 +49,13 @@ export function DashboardPage() {
})
const { data: platformData } = useQuery({
queryKey: ['platform/economics'],
queryFn: () => api.get('/platform/economics'),
queryKey: ['platform/economics', month],
queryFn: () => api.get('/platform/economics', { params: { month } }),
})
const { data: alertsData } = useQuery({
queryKey: ['sa-alerts'],
queryFn: () => api.get('/situational-awareness/alerts'),
queryKey: ['sa-alerts', month],
queryFn: () => api.get('/situational-awareness/alerts', { params: { month } }),
})
const { data: expenseData } = useQuery({
@@ -169,7 +166,6 @@ export function DashboardPage() {
}, {})
const p0p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
const pagedP0P1 = useMemo(() => p0p1Stores.slice((priorityPage - 1) * PAGE_SIZE, priorityPage * PAGE_SIZE), [p0p1Stores, priorityPage])
// 闭环健康度综合得分
const loopScore = lh ? (
@@ -500,9 +496,18 @@ export function DashboardPage() {
</div>
}
>
<Pagination page={priorityPage} pageSize={PAGE_SIZE} total={p0p1Stores.length} onPageChange={setPriorityPage} />
<div className="mt-3">
<DataTable
<FilterableTable
data={p0p1Stores}
sortOptions={[
{ key: 'problem_count', label: '问题数' },
{ key: 'received', label: '实收' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="problem_count"
defaultOrder="desc"
filterKey="action_priority"
filterLabel="全部优先级"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'action_priority', label: '优先级', render: (r) => <Badge type="priority" text={r.action_priority} /> },
@@ -510,10 +515,7 @@ export function DashboardPage() {
{ key: 'problem_combination', label: '问题组合' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
]}
data={pagedP0P1}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
</CollapsibleSection>
</div>
)
@@ -5,6 +5,7 @@ import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { Truck, AlertTriangle, PackageSearch, BarChart3 } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
@@ -27,7 +28,6 @@ function varianceText(pct: number): string {
export function DistributionReconciliationPage() {
const [month, setMonth] = useState('2026-04')
const [storeFilter, setStoreFilter] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['distribution-reconciliation', month],
@@ -42,12 +42,6 @@ export function DistributionReconciliationPage() {
const { summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation } = data
const filteredStores = storeReconciliation.filter((s: any) =>
!storeFilter ||
s.store_code?.includes(storeFilter) ||
s.store_name?.includes(storeFilter)
)
// 倒挤公式瀑布数据
const waterfallData = [
{ name: '期初库存', value: summary.total_opening_amt, type: 'base' },
@@ -79,9 +73,11 @@ export function DistributionReconciliationPage() {
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<Truck className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Truck className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -137,41 +133,30 @@ export function DistributionReconciliationPage() {
<Bar dataKey="期末库存" fill="#faad14" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
</tr>
</thead>
<tbody>
{categoryReconciliation.map((c: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-2 pr-4 font-medium">{c.minor_category}</td>
<td className="py-2 pr-4 text-right">{c.item_count}</td>
<td className="py-2 pr-4 text-right">{formatNumber(c.dist_qty)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.dist_amt)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.dist_cost_excl_tax)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.consumption_amt)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.ending_amt)}</td>
<td className={`py-2 pr-4 text-right ${c.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(c.variance_amt)}
</td>
<td className={`py-2 pr-4 text-right font-medium ${varianceColorClass(c.variance_pct)}`}>
{varianceText(c.variance_pct)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<FilterableTable
data={categoryReconciliation}
filterKey="minor_category"
filterLabel="全部分类"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'consumption_amt', label: '耗用金额' },
{ key: 'ending_amt', label: '期末库存' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
]}
columns={[
{ key: 'minor_category', label: '品类' },
{ key: 'item_count', label: '品项数', align: 'right' },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (c) => formatNumber(c.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (c) => formatCurrency(c.dist_amt) },
{ key: 'dist_cost_excl_tax', label: '不含税成本', align: 'right', render: (c) => formatCurrency(c.dist_cost_excl_tax) },
{ key: 'consumption_amt', label: '耗用金额', align: 'right', render: (c) => formatCurrency(c.consumption_amt) },
{ key: 'ending_amt', label: '期末库存', align: 'right', render: (c) => formatCurrency(c.ending_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (c) => <span className={c.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(c.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (c) => <span className={`font-medium ${varianceColorClass(c.variance_pct)}`}>{varianceText(c.variance_pct)}</span> },
]}
/>
</div>
</CollapsibleSection>
@@ -182,7 +167,17 @@ export function DistributionReconciliationPage() {
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="x" name="配送金额" tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} label={{ value: '配送金额', position: 'insideBottom', offset: -5, fontSize: 11 }} />
<YAxis type="number" dataKey="y" name="差异率%" tickFormatter={(v) => `${v.toFixed(0)}%`} tick={{ fontSize: 11 }} label={{ value: '差异率%', angle: -90, position: 'insideLeft', fontSize: 11 }} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, name: any) => name === 'x' ? formatCurrency(v) : `${v.toFixed(2)}%`} labelFormatter={() => ''} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} content={({ active, payload }: any) => {
if (!active || !payload?.length) return null
const p = payload[0].payload
return (
<div className="rounded border bg-white px-3 py-2 text-xs shadow">
<div className="font-medium">{p.name} ({p.store_code})</div>
<div>: {formatCurrency(p.x)}</div>
<div>: {p.y.toFixed(2)}%</div>
</div>
)
}} />
<Scatter data={storeScatterData}>
{storeScatterData.map((entry: any, idx: number) => (
<Cell key={idx} fill={
@@ -204,135 +199,88 @@ export function DistributionReconciliationPage() {
{/* 门店对账明细 */}
<CollapsibleSection
title="门店对账明细"
subtitle={`${filteredStores.length}家门店`}
headerRight={
<input
type="text"
placeholder="搜索门店编码/名称..."
value={storeFilter}
onChange={(e) => setStoreFilter(e.target.value)}
className="rounded border px-3 py-1 text-sm"
/>
}
subtitle={`${storeReconciliation.length}家门店`}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{filteredStores.map((s: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{s.store_code}</td>
<td className="py-1.5 pr-3 font-medium">{s.store_name || '-'}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.dist_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.dist_cost_excl_tax)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.opening_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.consumption_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.ending_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.reverse_consumption_amt)}</td>
<td className={`py-1.5 pr-3 text-right ${s.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(s.variance_amt)}
</td>
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColorClass(s.variance_pct)}`}>
{varianceText(s.variance_pct)}
</td>
<td className={`py-1.5 pr-3 text-right ${s.neg_inventory_count > 0 ? 'text-red-600 font-medium' : ''}`}>
{s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<FilterableTable
data={storeReconciliation}
searchKeys={['store_code', 'store_name']}
searchPlaceholder="搜索门店编码/名称..."
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'dist_cost_excl_tax', label: '不含税成本' },
{ key: 'opening_amt', label: '期初库存' },
{ key: 'consumption_amt', label: '实际耗用' },
{ key: 'ending_amt', label: '期末库存' },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
{ key: 'neg_inventory_count', label: '负库存' },
]}
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称', render: (s) => s.store_name || '-' },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (s) => formatCurrency(s.dist_amt) },
{ key: 'dist_cost_excl_tax', label: '不含税成本', align: 'right', render: (s) => formatCurrency(s.dist_cost_excl_tax) },
{ key: 'opening_amt', label: '期初库存', align: 'right', render: (s) => formatCurrency(s.opening_amt) },
{ key: 'consumption_amt', label: '实际耗用', align: 'right', render: (s) => formatCurrency(s.consumption_amt) },
{ key: 'ending_amt', label: '期末库存', align: 'right', render: (s) => formatCurrency(s.ending_amt) },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用', align: 'right', render: (s) => formatCurrency(s.reverse_consumption_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (s) => <span className={s.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(s.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (s) => <span className={`font-medium ${varianceColorClass(s.variance_pct)}`}>{varianceText(s.variance_pct)}</span> },
{ key: 'neg_inventory_count', label: '负库存', align: 'right', render: (s) => <span className={s.neg_inventory_count > 0 ? 'text-red-600 font-medium' : ''}>{s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'}</span> },
]}
/>
</CollapsibleSection>
{/* Top差异品项 */}
<CollapsibleSection title="Top30差异品项" subtitle="按差异金额绝对值排序" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{topVariances.map((v: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{v.store_code}</td>
<td className="py-1.5 pr-3 text-xs">{v.item_code}</td>
<td className="py-1.5 pr-3 font-medium">{v.item_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{v.minor_category}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(v.dist_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.dist_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.opening_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.consumption_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.ending_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.reverse_consumption_amt)}</td>
<td className={`py-1.5 pr-3 text-right font-medium ${v.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(v.variance_amt)}
</td>
<td className={`py-1.5 pr-3 text-right ${varianceColorClass(v.variance_pct)}`}>
{varianceText(v.variance_pct)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<FilterableTable
data={topVariances}
filterKey="item_name"
filterLabel="全部品项"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'consumption_amt', label: '实际耗用' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
]}
columns={[
{ key: 'store_code', label: '门店' },
{ key: 'item_code', label: '品项编码' },
{ key: 'item_name', label: '品项名称' },
{ key: 'minor_category', label: '品类' },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (v) => formatNumber(v.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (v) => formatCurrency(v.dist_amt) },
{ key: 'opening_amt', label: '期初', align: 'right', render: (v) => formatCurrency(v.opening_amt) },
{ key: 'consumption_amt', label: '实际耗用', align: 'right', render: (v) => formatCurrency(v.consumption_amt) },
{ key: 'ending_amt', label: '期末', align: 'right', render: (v) => formatCurrency(v.ending_amt) },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用', align: 'right', render: (v) => formatCurrency(v.reverse_consumption_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (v) => <span className={`font-medium ${v.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>{formatCurrency(v.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (v) => <span className={varianceColorClass(v.variance_pct)}>{varianceText(v.variance_pct)}</span> },
]}
/>
</CollapsibleSection>
{/* 未匹配品项 */}
<CollapsibleSection title="未匹配品项(有配送无库存耗用)" subtitle="配送系统有发货但库存系统无耗用记录" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{unmatchedItems.map((u: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{u.item_code}</td>
<td className="py-1.5 pr-3 font-medium">{u.item_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{u.minor_category}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(u.dist_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(u.dist_amt)}</td>
<td className="py-1.5 pr-3 text-right">{u.store_count}</td>
</tr>
))}
</tbody>
</table>
</div>
<FilterableTable
data={unmatchedItems}
filterKey="item_name"
filterLabel="全部品项"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'dist_qty', label: '配送量' },
{ key: 'store_count', label: '涉及门店数' },
]}
columns={[
{ key: 'item_code', label: '品项编码' },
{ key: 'item_name', label: '品项名称' },
{ key: 'minor_category', label: '品类' },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (u) => formatNumber(u.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (u) => formatCurrency(u.dist_amt) },
{ key: 'store_count', label: '涉及门店数', align: 'right' },
]}
/>
<div className="mt-3 flex items-center gap-2 rounded-md bg-yellow-50 p-3 text-xs text-yellow-800">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span></span>
+12 -10
View File
@@ -1,26 +1,29 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 10
import { FilterableTable } from '@/components/FilterableTable'
export function IndicatorsPage() {
const [page, setPage] = useState(1)
const { data } = useQuery({
queryKey: ['indicators'],
queryFn: () => api.get('/tasks/indicators'),
})
const indicators = (data as any)?.data || []
const pagedIndicators = useMemo(() => indicators.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [indicators, page])
return (
<div className="space-y-4">
<h1 className="text-xl font-bold"></h1>
<Pagination page={page} pageSize={PAGE_SIZE} total={indicators.length} onPageChange={setPage} />
<DataTable
<FilterableTable
data={indicators}
sortOptions={[
{ key: 'indicator_name', label: '指标名称' },
{ key: 'update_frequency', label: '更新频率' },
{ key: 'owner', label: '负责人' },
]}
defaultSort="indicator_name"
defaultOrder="asc"
filterKey="update_frequency"
filterLabel="全部频率"
columns={[
{ key: 'indicator_name', label: '指标名称' },
{ key: 'business_definition', label: '业务定义', render: (r) => <span className="text-xs">{r.business_definition}</span> },
@@ -31,7 +34,6 @@ export function IndicatorsPage() {
{ key: 'red_threshold', label: '红线', align: 'right' },
{ key: 'version_date', label: '版本日期', render: (r) => r.version_date?.substring(0, 10) },
]}
data={pagedIndicators}
/>
</div>
)
+39 -29
View File
@@ -1,24 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
export function MemberPage() {
const [repeatPage, setRepeatPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: comparisonData, isLoading: cmpLoading } = useQuery({
queryKey: ['member/comparison'],
queryFn: () => api.get('/member/comparison'),
queryKey: ['member/comparison', month],
queryFn: () => api.get('/member/comparison', { params: { month } }),
})
const { data: repeatData, isLoading: repeatLoading } = useQuery({
@@ -60,8 +56,6 @@ export function MemberPage() {
]
const PIE_COLORS = ['#3b82f6', '#e5e7eb']
const pagedRepeat = useMemo(() => repeatRows.slice((repeatPage - 1) * PAGE_SIZE, repeatPage * PAGE_SIZE), [repeatRows, repeatPage])
if (pageLoading) {
return <LoadingSpinner text="加载会员数据..." />
}
@@ -120,7 +114,16 @@ export function MemberPage() {
{/* 会员对比明细 */}
<CollapsibleSection title="会员与非会员指标对比" subtitle="优惠率、毛利率等关键指标">
<DataTable
<FilterableTable
data={cmpRows}
sortOptions={[
{ key: 'customer_type', label: '客户类型' },
{ key: 'bill_count', label: '账单数' },
{ key: 'received', label: '实收' },
{ key: 'avg_bill_value', label: '客单价' },
]}
defaultSort="customer_type"
defaultOrder="asc"
columns={[
{ key: 'customer_type', label: '客户类型' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
@@ -129,29 +132,36 @@ export function MemberPage() {
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
]}
data={cmpRows}
/>
</CollapsibleSection>
{/* 门店复购率 */}
<CollapsibleSection title={`门店复购率 (${repeatRows.length})`} subtitle="按复购率降序,低于30%需重点改善">
<Pagination page={repeatPage} pageSize={PAGE_SIZE} total={repeatRows.length} onPageChange={setRepeatPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'identified_members', label: '识别会员数', align: 'right', render: (r) => formatNumber(r.identified_members) },
{ key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) },
{ key: 'repeat_rate_pct', label: '复购', align: 'right', render: (r) => {
const v = Number(r.repeat_rate_pct || 0)
return <span className={v >= 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) },
{ key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` },
]}
data={pagedRepeat}
/>
</div>
<FilterableTable
data={repeatRows}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'repeat_rate_pct', label: '复购率' },
{ key: 'identified_members', label: '会员数' },
{ key: 'repeat_members', label: '复购' },
{ key: 'avg_orders', label: '消费次数' },
{ key: 'repeat_revenue_share_pct', label: '收入占比' },
]}
defaultSort="repeat_rate_pct"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'identified_members', label: '识别会员数', align: 'right', render: (r) => formatNumber(r.identified_members) },
{ key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) },
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => {
const v = Number(r.repeat_rate_pct || 0)
return <span className={v >= 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) },
{ key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` },
]}
/>
</CollapsibleSection>
</div>
)
+69 -28
View File
@@ -1,16 +1,14 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, YAxis, Legend } from 'recharts'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { Badge } from '@/components/Badge'
import { MetricCard } from '@/components/MetricCard'
import { Pagination } from '@/components/Pagination'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' }
const PAGE_SIZE = 10
type ReviewTab = 'summary' | 'grade-change' | 'completion' | 'activity' | 'sku' | 'practice' | 'effectiveness'
@@ -25,8 +23,7 @@ const REVIEW_TABS: { key: ReviewTab; label: string }[] = [
]
export function MonthlyReviewPage() {
const [month, setMonth] = useState('2026-05')
const [page, setPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const [tab, setTab] = useState<ReviewTab>('summary')
const { data } = useQuery({
@@ -35,8 +32,8 @@ export function MonthlyReviewPage() {
})
const { data: gradeData } = useQuery({
queryKey: ['grade-change'],
queryFn: () => api.get('/tasks/grade-change'),
queryKey: ['grade-change', month],
queryFn: () => api.get('/tasks/grade-change', { params: { month } }),
enabled: tab === 'grade-change',
})
@@ -47,14 +44,14 @@ export function MonthlyReviewPage() {
})
const { data: activityData } = useQuery({
queryKey: ['monthly-review/activity-list'],
queryFn: () => api.get('/tasks/monthly-review/activity-list'),
queryKey: ['monthly-review/activity-list', month],
queryFn: () => api.get('/tasks/monthly-review/activity-list', { params: { month } }),
enabled: tab === 'activity',
})
const { data: skuGovData } = useQuery({
queryKey: ['monthly-review/sku-governance'],
queryFn: () => api.get('/tasks/monthly-review/sku-governance'),
queryKey: ['monthly-review/sku-governance', month],
queryFn: () => api.get('/tasks/monthly-review/sku-governance', { params: { month } }),
enabled: tab === 'sku',
})
@@ -67,7 +64,6 @@ export function MonthlyReviewPage() {
const rd = (data as any)?.data
const summary = rd?.summary || { total: 0, passed: 0, improving: 0, failed: 0 }
const details = rd?.details || []
const pagedDetails = useMemo(() => details.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [details, page])
const gradeRows = (gradeData as any)?.data || []
const completionRows = (completionData as any)?.data || []
@@ -124,9 +120,19 @@ export function MonthlyReviewPage() {
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<Pagination page={page} pageSize={PAGE_SIZE} total={details.length} onPageChange={setPage} />
<div className="mt-3">
<DataTable
<FilterableTable
data={details}
sortOptions={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'baseline_value', label: '基线' },
{ key: 'target_value', label: '目标' },
{ key: 'actual_value', label: '实际' },
]}
defaultSort="store_name"
defaultOrder="asc"
filterKey="review_result"
filterLabel="全部结果"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
@@ -135,9 +141,7 @@ export function MonthlyReviewPage() {
{ key: 'actual_value', label: '实际', align: 'right' },
{ key: 'review_result', label: '结果', render: (r) => <Badge type="review" text={r.review_result} /> },
]}
data={pagedDetails}
/>
</div>
</div>
</>
)}
@@ -147,7 +151,17 @@ export function MonthlyReviewPage() {
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold">P0/P1/P2升降级面板</h2>
{gradeRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
<FilterableTable
data={gradeRows}
sortOptions={[
{ key: 'change_month', label: '月份' },
{ key: 'store_name', label: '门店' },
{ key: 'change_type', label: '变化' },
]}
defaultSort="change_month"
defaultOrder="desc"
filterKey="change_type"
filterLabel="全部变化"
columns={[
{ key: 'change_month', label: '月份', render: (r) => r.change_month?.substring(0, 7) },
{ key: 'store_name', label: '门店' },
@@ -159,7 +173,6 @@ export function MonthlyReviewPage() {
} },
{ key: 'reason', label: '原因', render: (r) => <span className="text-xs">{r.reason}</span> },
]}
data={gradeRows}
/>
)}
</div>
@@ -183,7 +196,14 @@ export function MonthlyReviewPage() {
</div>
))}
</div>
<DataTable
<FilterableTable
data={completionRows}
sortOptions={[
{ key: 'priority', label: '优先级' },
{ key: 'completion_rate', label: '完成率' },
]}
defaultSort="priority"
defaultOrder="asc"
columns={[
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
{ key: 'total', label: '总数', align: 'right' },
@@ -193,7 +213,6 @@ export function MonthlyReviewPage() {
{ key: 'failed', label: '未改善', align: 'right', render: (r) => <span className="text-red-600">{r.failed}</span> },
{ key: 'completion_rate', label: '完成率', align: 'right', render: (r) => `${r.completion_rate}%` },
]}
data={completionRows}
/>
</>
)}
@@ -206,7 +225,16 @@ export function MonthlyReviewPage() {
<h2 className="mb-3 text-sm font-bold">//</h2>
<p className="mb-3 text-xs text-muted-foreground"></p>
{activityRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
<FilterableTable
data={activityRows}
sortOptions={[
{ key: 'net_contribution', label: '净贡献' },
{ key: 'received', label: '实收' },
{ key: 'discount_cost_rate', label: '优惠成本率' },
{ key: 'bill_count', label: '账单数' },
]}
defaultSort="net_contribution"
defaultOrder="desc"
columns={[
{ key: 'marketing_plan', label: '活动方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
@@ -223,7 +251,6 @@ export function MonthlyReviewPage() {
return <span className="text-yellow-600"></span>
} },
]}
data={activityRows}
/>
)}
</div>
@@ -246,7 +273,15 @@ export function MonthlyReviewPage() {
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold">SKU治理清单 (C类)</h2>
{skuGov?.longtail?.length > 0 ? (
<DataTable
<FilterableTable
data={skuGov.longtail}
sortOptions={[
{ key: 'received_amount', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'revenue_share_pct', label: '营收占比' },
]}
defaultSort="received_amount"
defaultOrder="desc"
columns={[
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_level1', label: '一级分类' },
@@ -254,7 +289,6 @@ export function MonthlyReviewPage() {
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
{ key: 'revenue_share_pct', label: '营收占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
]}
data={skuGov.longtail.slice(0, 20)}
/>
) : <p className="text-sm text-muted-foreground">SKU数据</p>}
</div>
@@ -276,7 +310,15 @@ export function MonthlyReviewPage() {
<h2 className="mb-3 text-sm font-bold"></h2>
<p className="mb-3 text-xs text-muted-foreground"></p>
{effectivenessRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
<FilterableTable
data={effectivenessRows}
sortOptions={[
{ key: 'pass_rate', label: '达标率' },
{ key: 'task_count', label: '任务数' },
{ key: 'problem_indicator', label: '指标' },
]}
defaultSort="pass_rate"
defaultOrder="desc"
columns={[
{ key: 'problem_indicator', label: '指标' },
{ key: 'task_count', label: '任务数', align: 'right' },
@@ -294,7 +336,6 @@ export function MonthlyReviewPage() {
return <Badge type="review" text="低效" />
} },
]}
data={effectivenessRows}
/>
)}
</div>
+24 -7
View File
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { useState } from 'react'
import { Database, Table, Layers, Settings, ChevronRight, Box, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -189,7 +189,12 @@ export function OntologyPage() {
</button>
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
<div className="rounded-lg border">
<DataTable
<FilterableTable
data={dim?.rows || []}
sortOptions={dim?.columns?.slice(0, 5).map((c: any) => ({ key: c.column_name, label: c.column_name })) || []}
defaultSort={dim?.columns?.[0]?.column_name || ''}
defaultOrder="asc"
pageSize={20}
columns={dim?.columns?.map((c: any) => ({
key: c.column_name,
label: c.column_name,
@@ -201,7 +206,6 @@ export function OntologyPage() {
return <span className="text-xs">{String(val)}</span>
}
})) || []}
data={dim?.rows || []}
/>
</div>
<div className="text-xs text-muted-foreground"></div>
@@ -294,9 +298,12 @@ export function OntologyPage() {
</button>
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
<div className="rounded-lg border">
<DataTable
columns={Object.keys(enumRows[0] || {}).map(k => ({ key: k, label: k }))}
<FilterableTable
data={enumRows}
sortOptions={Object.keys(enumRows[0] || {}).slice(0, 3).map(k => ({ key: k, label: k }))}
defaultSort={Object.keys(enumRows[0] || {})[0] || ''}
defaultOrder="asc"
columns={Object.keys(enumRows[0] || {}).map(k => ({ key: k, label: k }))}
/>
</div>
</div>
@@ -316,7 +323,18 @@ export function OntologyPage() {
))}
</div>
<div className="rounded-lg border">
<DataTable
<FilterableTable
data={metricFilter === '全部' ? metrics : metrics.filter((m: any) => m.metric_category === metricFilter)}
sortOptions={[
{ key: 'indicator_name', label: '指标名称' },
{ key: 'metric_category', label: '分类' },
{ key: 'owner', label: '负责人' },
{ key: 'update_frequency', label: '频率' },
]}
defaultSort="indicator_name"
defaultOrder="asc"
filterKey="metric_category"
filterLabel="全部分类"
columns={[
{ key: 'metric_code', label: '指标编码', render: (r: any) => <code className="text-xs text-blue-600">{r.metric_code || '—'}</code> },
{ key: 'indicator_name', label: '指标名称', render: (r: any) => <span className="font-medium">{r.indicator_name}</span> },
@@ -331,7 +349,6 @@ export function OntologyPage() {
{ key: 'update_frequency', label: '频率', align: 'center' as const },
{ key: 'version', label: '版本', render: (r: any) => <span className="text-xs text-muted-foreground">{r.version || '—'}</span> },
]}
data={metricFilter === '全部' ? metrics : metrics.filter((m: any) => m.metric_category === metricFilter)}
/>
</div>
</div>
+57 -53
View File
@@ -1,20 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
export function PlatformPage() {
const [platformPage, setPlatformPage] = useState(1)
const [marketingPage, setMarketingPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: platformData, isLoading: platformLoading } = useQuery({
@@ -23,8 +18,8 @@ export function PlatformPage() {
})
const { data: marketingData, isLoading: marketingLoading } = useQuery({
queryKey: ['marketing/plans'],
queryFn: () => api.get('/marketing/plans'),
queryKey: ['marketing/plans', month],
queryFn: () => api.get('/marketing/plans', { params: { month } }),
})
const platformRows = (platformData as any)?.data || []
@@ -56,9 +51,6 @@ export function PlatformPage() {
{ name: '京东', 实收: totalJd, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_commission || 0), 0) },
]
const pagedPlatform = useMemo(() => storesWithPlatform.slice((platformPage - 1) * PAGE_SIZE, platformPage * PAGE_SIZE), [storesWithPlatform, platformPage])
const pagedMarketing = useMemo(() => marketingRows.slice((marketingPage - 1) * PAGE_SIZE, marketingPage * PAGE_SIZE), [marketingRows, marketingPage])
if (pageLoading) {
return <LoadingSpinner text="加载平台数据..." />
}
@@ -101,51 +93,63 @@ export function PlatformPage() {
{/* 门店平台经济性 */}
<CollapsibleSection title={`门店平台经济性 (${storesWithPlatform.length})`} subtitle="各门店三平台实收与成本率">
<Pagination page={platformPage} pageSize={PAGE_SIZE} total={storesWithPlatform.length} onPageChange={setPlatformPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'meituan_received', label: '美团实收', align: 'right', render: (r) => formatCurrency(r.meituan_received) },
{ key: 'meituan_cost_rate_pct', label: '美团成本率', align: 'right', render: (r) => {
const v = Number(r.meituan_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'taobao_received', label: '淘宝实收', align: 'right', render: (r) => formatCurrency(r.taobao_received) },
{ key: 'taobao_cost_rate_pct', label: '淘宝成本率', align: 'right', render: (r) => {
const v = Number(r.taobao_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'jd_received', label: '京东实收', align: 'right', render: (r) => formatCurrency(r.jd_received) },
{ key: 'jd_cost_rate_pct', label: '京东成本率', align: 'right', render: (r) => {
const v = Number(r.jd_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
]}
data={pagedPlatform}
/>
</div>
<FilterableTable
data={storesWithPlatform}
sortOptions={[
{ key: 'meituan_received', label: '美团实收' },
{ key: 'meituan_cost_rate_pct', label: '美团成本率' },
{ key: 'taobao_received', label: '淘宝实收' },
{ key: 'jd_received', label: '京东实收' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="meituan_received"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'meituan_received', label: '美团实收', align: 'right', render: (r) => formatCurrency(r.meituan_received) },
{ key: 'meituan_cost_rate_pct', label: '美团成本率', align: 'right', render: (r) => {
const v = Number(r.meituan_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'taobao_received', label: '淘宝实收', align: 'right', render: (r) => formatCurrency(r.taobao_received) },
{ key: 'taobao_cost_rate_pct', label: '淘宝成本率', align: 'right', render: (r) => {
const v = Number(r.taobao_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'jd_received', label: '京东实收', align: 'right', render: (r) => formatCurrency(r.jd_received) },
{ key: 'jd_cost_rate_pct', label: '京东成本率', align: 'right', render: (r) => {
const v = Number(r.jd_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
]}
/>
</CollapsibleSection>
{/* 营销方案ROI */}
<CollapsibleSection title={`营销方案分析 (${marketingRows.length})`} subtitle="各营销方案的订单、实收、优惠率与毛利率">
<Pagination page={marketingPage} pageSize={PAGE_SIZE} total={marketingRows.length} onPageChange={setMarketingPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'marketing_plan', label: '营销方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => {
const v = Number(r.discount_rate_pct || 0)
return <span className={v >= 25 ? 'font-medium text-red-600' : v >= 15 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
]}
data={pagedMarketing}
/>
</div>
<FilterableTable
data={marketingRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'discount_rate_pct', label: '优惠率' },
{ key: 'theoretical_margin_pct', label: '毛利率' },
]}
defaultSort="received"
defaultOrder="desc"
columns={[
{ key: 'marketing_plan', label: '营销方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => {
const v = Number(r.discount_rate_pct || 0)
return <span className={v >= 25 ? 'font-medium text-red-600' : v >= 15 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
]}
/>
</CollapsibleSection>
</div>
)
+14 -5
View File
@@ -68,9 +68,11 @@ export function ProductionPlanPage() {
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<TrendingUp className="h-6 w-6 text-green-600" />
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="h-6 w-6 text-green-600" />
<h1 className="text-xl font-bold"></h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -79,12 +81,15 @@ export function ProductionPlanPage() {
<strong></strong> SKU销量 × BOM = <strong></strong> + <strong></strong> - +
</div>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
{/* 核心指标 - 第一行:菜品与BOM */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总菜品数" value={summary.total_dish_count} unit="个" />
<MetricCard title="有BOM菜品" value={summary.matched_sku_count} unit="个" status="good" />
<MetricCard title="BOM覆盖率" value={`${summary.bom_coverage_pct}%`} status={summary.bom_coverage_pct >= 50 ? 'good' : 'warn'} />
<MetricCard title="BOM原料数" value={summary.total_bom_materials} unit="种" />
</div>
{/* 核心指标 - 第二行:销售额与生产计划 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="匹配销售额" value={summary.matched_amt} format="currency" description="有BOM菜品的销售额" />
<MetricCard title="总销售额" value={summary.total_sales_amt} format="currency" />
<MetricCard title="CK生产计划" value={ckProductionPlan.filter((p: any) => p.demand_qty > 0).length} unit="种" status="warn" description="有销量需求的半成品数" />
@@ -213,6 +218,10 @@ export function ProductionPlanPage() {
{/* 门店要货建议 */}
<CollapsibleSection title="门店要货建议" subtitle={`${storeDemand.length}家门店的原料需求与库存对比`} defaultOpen={false}>
<div className="mb-3 flex items-center gap-2">
<Package className="h-4 w-4 text-green-600" />
<span className="text-sm text-muted-foreground"> = - </span>
</div>
<FilterableTable
data={storeDemand}
filterKey="store_name"
+71 -55
View File
@@ -2,51 +2,45 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { MetricCard } from '@/components/MetricCard'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, riskColor } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MonthPicker } from '@/components/MonthPicker'
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 10
import { useState } from 'react'
export function RegionalPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [riskFilter, setRiskFilter] = useState('')
const [month, setMonth] = useState('2026-05')
const [riskPage, setRiskPage] = useState(1)
const [checkPage, setCheckPage] = useState(1)
const [benchPage, setBenchPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const [weeklyModal, setWeeklyModal] = useState<{ taskId: number; storeName: string } | null>(null)
const [weeklyText, setWeeklyText] = useState('')
const { data: riskData, isLoading: riskLoading } = useQuery({
queryKey: ['stores/risk'],
queryFn: () => api.get('/stores/risk'),
queryKey: ['stores/risk', month],
queryFn: () => api.get('/stores/risk', { params: { month } }),
})
const { data: weeklyData, isLoading: weeklyLoading } = useQuery({
queryKey: ['tasks/weekly-check'],
queryKey: ['tasks/weekly-check', month],
queryFn: () => api.get('/tasks/weekly-check', { params: { month } }),
})
const { data: priorityData, isLoading: priorityLoading } = useQuery({
queryKey: ['stores/priority'],
queryFn: () => api.get('/stores/priority'),
queryKey: ['stores/priority', month],
queryFn: () => api.get('/stores/priority', { params: { month } }),
})
const { data: benchmarkData } = useQuery({
queryKey: ['benchmark/composite'],
queryFn: () => api.get('/benchmark/composite'),
queryKey: ['benchmark/composite', month],
queryFn: () => api.get('/benchmark/composite', { params: { month } }),
})
const { data: regionData } = useQuery({
queryKey: ['region/summary'],
queryFn: () => api.get('/region/summary'),
queryKey: ['region/summary', month],
queryFn: () => api.get('/region/summary', { params: { month } }),
})
const riskRows = ((riskData as any)?.data || []).filter((r: any) => !riskFilter || r.risk_level === riskFilter)
@@ -58,10 +52,6 @@ export function RegionalPage() {
const p0p1 = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
const needCheck = weeklyRows.filter((w: any) => parseInt(w.consecutive_no_improve_weeks) >= 2)
const pagedRiskRows = useMemo(() => riskRows.slice((riskPage - 1) * PAGE_SIZE, riskPage * PAGE_SIZE), [riskRows, riskPage])
const pagedNeedCheck = useMemo(() => needCheck.slice((checkPage - 1) * PAGE_SIZE, checkPage * PAGE_SIZE), [needCheck, checkPage])
const pagedBenchmark = useMemo(() => benchmarkRows.slice((benchPage - 1) * PAGE_SIZE, benchPage * PAGE_SIZE), [benchmarkRows, benchPage])
const pageLoading = riskLoading || weeklyLoading || priorityLoading
const weeklyMutation = useMutation({
@@ -96,7 +86,16 @@ export function RegionalPage() {
{regionRows.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<DataTable
<FilterableTable
data={regionRows}
sortOptions={[
{ key: 'region', label: '区域' },
{ key: 'total_received', label: '实收' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'store_count', label: '门店数' },
]}
defaultSort="total_received"
defaultOrder="desc"
columns={[
{ key: 'region', label: '区域' },
{ key: 'store_count', label: '门店数', align: 'right' },
@@ -109,7 +108,6 @@ export function RegionalPage() {
{ key: 'yellow_count', label: '黄', align: 'center', render: (r) => <span className={r.yellow_count > 0 ? 'font-bold text-yellow-600' : ''}>{r.yellow_count}</span> },
{ key: 'green_count', label: '绿', align: 'center', render: (r) => <span className="text-green-600">{r.green_count}</span> },
]}
data={regionRows}
/>
</div>
)}
@@ -130,9 +128,20 @@ export function RegionalPage() {
{/* 门店红黄绿列表 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({riskRows.length})</h2>
<Pagination page={riskPage} pageSize={PAGE_SIZE} total={riskRows.length} onPageChange={setRiskPage} />
<div className="mt-3">
<DataTable
<FilterableTable
data={riskRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'discount_rate_pct', label: '优惠率' },
{ key: 'theoretical_margin_pct', label: '毛利率' },
{ key: 'anomaly_rate_pct', label: '异常率' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="received"
defaultOrder="desc"
filterKey="risk_level"
filterLabel="全部风险"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'risk_level', label: '风险', render: (r) => <Badge type="risk" text={r.risk_level} /> },
@@ -141,19 +150,22 @@ export function RegionalPage() {
{ key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => formatPercent(r.anomaly_rate_pct) },
]}
data={pagedRiskRows}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
</div>
{/* 需到店检查门店 + 周检录入 */}
{needCheck.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold text-red-600">2({needCheck.length})</h2>
<Pagination page={checkPage} pageSize={PAGE_SIZE} total={needCheck.length} onPageChange={setCheckPage} />
<div className="mt-3">
<DataTable
<FilterableTable
data={needCheck}
sortOptions={[
{ key: 'consecutive_no_improve_weeks', label: '未改善周数' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="consecutive_no_improve_weeks"
defaultOrder="desc"
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
@@ -163,10 +175,7 @@ export function RegionalPage() {
<button onClick={(e) => { e.stopPropagation(); setWeeklyModal({ taskId: r.task_id, storeName: r.store_name }); setWeeklyText('') }} className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted"></button>
) },
]}
data={pagedNeedCheck}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
/>
</div>
</div>
)}
@@ -174,25 +183,32 @@ export function RegionalPage() {
{benchmarkRows.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<Pagination page={benchPage} pageSize={PAGE_SIZE} total={benchmarkRows.length} onPageChange={setBenchPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'benchmark_score', label: '标杆得分', align: 'right', render: (r) => {
const v = Number(r.benchmark_score || 0)
return <span className={v >= 80 ? 'text-green-600' : v >= 60 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}</span>
} },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
{ key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'member_bill_share_pct', label: '会员占比', align: 'right', render: (r) => formatPercent(r.member_bill_share_pct) },
]}
data={pagedBenchmark}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
<FilterableTable
data={benchmarkRows}
sortOptions={[
{ key: 'benchmark_score', label: '标杆得分' },
{ key: 'received', label: '实收' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'discount_rate_pct', label: '优惠率' },
{ key: 'theoretical_margin_pct', label: '毛利率' },
{ key: 'member_bill_share_pct', label: '会员占比' },
]}
defaultSort="benchmark_score"
defaultOrder="desc"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'benchmark_score', label: '标杆得分', align: 'right', render: (r) => {
const v = Number(r.benchmark_score || 0)
return <span className={v >= 80 ? 'text-green-600' : v >= 60 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}</span>
} },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
{ key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'member_bill_share_pct', label: '会员占比', align: 'right', render: (r) => formatPercent(r.member_bill_share_pct) },
]}
/>
</div>
)}
+19 -13
View File
@@ -7,6 +7,8 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { BarChart3, TrendingDown, Users, Receipt } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { useState } from 'react'
const CHANNEL_COLORS: Record<string, string> = {
wechat: '#07c160',
@@ -23,22 +25,23 @@ const CHANNEL_COLORS: Record<string, string> = {
export function RevenuePage() {
const navigate = useNavigate()
const [month, setMonth] = useState('2026-04')
const { data: dailyData, isLoading: dailyLoading } = useQuery({
queryKey: ['revenue/daily-summary'],
queryFn: () => api.get('/revenue/daily-summary'),
queryKey: ['revenue/daily-summary', month],
queryFn: () => api.get('/revenue/daily-summary', { params: { month } }),
})
const { data: channelData, isLoading: channelLoading } = useQuery({
queryKey: ['revenue/channel'],
queryFn: () => api.get('/revenue/channel'),
queryKey: ['revenue/channel', month],
queryFn: () => api.get('/revenue/channel', { params: { month } }),
})
const { data: mealPeriodData, isLoading: mealLoading } = useQuery({
queryKey: ['revenue/meal-period'],
queryFn: () => api.get('/revenue/meal-period'),
queryKey: ['revenue/meal-period', month],
queryFn: () => api.get('/revenue/meal-period', { params: { month } }),
})
const { data: storeRankingData, isLoading: rankLoading } = useQuery({
queryKey: ['revenue/store-ranking'],
queryFn: () => api.get('/revenue/store-ranking'),
queryKey: ['revenue/store-ranking', month],
queryFn: () => api.get('/revenue/store-ranking', { params: { month } }),
})
const pageLoading = dailyLoading || channelLoading || mealLoading || rankLoading
@@ -138,12 +141,15 @@ export function RevenuePage() {
return (
<div className="space-y-4">
{/* 标题 */}
<div className="flex items-center gap-2">
<BarChart3 className="text-blue-500" size={24} />
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · · 20264</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="text-blue-500" size={24} />
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · </p>
</div>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 核心指标 */}
+167 -88
View File
@@ -1,65 +1,88 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState, useEffect } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
const ANOMALY_PAGE_SIZE = 20
function SectionLoading({ text }: { text: string }) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-muted border-t-primary" />
{text}
</div>
)
}
export function RiskPage() {
const [anomalyPage, setAnomalyPage] = useState(1)
const [cashierPage, setCashierPage] = useState(1)
const [zeroPage, setZeroPage] = useState(1)
const [riskTab, setRiskTab] = useState<'anomaly' | 'zero' | 'cashier'>('anomaly')
const [month, setMonth] = useState('2026-04')
const [anomalyPage, setAnomalyPage] = useState(1)
const [anomalyStore, setAnomalyStore] = useState('')
const [anomalyReason, setAnomalyReason] = useState('')
const [anomalyCashier, setAnomalyCashier] = useState('')
const [debouncedCashier, setDebouncedCashier] = useState('')
const { data: anomalyData, isLoading: anomalyLoading } = useQuery({
queryKey: ['risk/anomaly'],
queryFn: () => api.get('/risk/anomaly'),
useEffect(() => {
const t = setTimeout(() => setDebouncedCashier(anomalyCashier), 400)
return () => clearTimeout(t)
}, [anomalyCashier])
const { data: anomalyData, isLoading: anomalyLoading, isFetching: anomalyFetching } = useQuery({
queryKey: ['risk/anomaly', month, anomalyPage, anomalyStore, anomalyReason, debouncedCashier],
queryFn: () => api.get('/risk/anomaly', { params: { month, page: anomalyPage, page_size: ANOMALY_PAGE_SIZE, ...(anomalyStore ? { store: anomalyStore } : {}), ...(anomalyReason ? { reason: anomalyReason } : {}), ...(debouncedCashier ? { cashier: debouncedCashier } : {}) } }),
placeholderData: keepPreviousData,
})
const { data: zeroData, isLoading: zeroLoading } = useQuery({
queryKey: ['risk/zero-received'],
queryFn: () => api.get('/risk/zero-received'),
const { data: zeroData, isLoading: zeroLoading, isFetching: zeroFetching } = useQuery({
queryKey: ['risk/zero-received', month],
queryFn: () => api.get('/risk/zero-received', { params: { month } }),
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
})
const { data: cashierData, isLoading: cashierLoading } = useQuery({
queryKey: ['risk/cashier'],
queryFn: () => api.get('/risk/cashier'),
const { data: cashierData, isLoading: cashierLoading, isFetching: cashierFetching } = useQuery({
queryKey: ['risk/cashier', month],
queryFn: () => api.get('/risk/cashier', { params: { month } }),
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
})
const anomalyRows = (anomalyData as any)?.data || []
const anomalyMeta = (anomalyData as any)?.meta || { total: 0 }
const zeroRows = (zeroData as any)?.data || []
const cashierRows = (cashierData as any)?.data || []
const storeNames: string[] = Array.from(new Set<string>(cashierRows.map((r: any) => r.store_name as string).filter(Boolean))).sort()
const pageLoading = anomalyLoading || zeroLoading || cashierLoading
const initialLoading = !anomalyData && !zeroData && !cashierData
const totalAnomaly = anomalyRows.length
const totalAnomaly = anomalyMeta.total || anomalyRows.length
const totalZero = zeroRows.length
const highRiskCashiers = cashierRows.filter((r: any) => Number(r.anomaly_rate_pct || 0) >= 10).length
const avgAnomalyRate = cashierRows.length > 0
? cashierRows.reduce((s: number, r: any) => s + Number(r.anomaly_rate_pct || 0), 0) / cashierRows.length
: 0
const pagedAnomaly = useMemo(() => anomalyRows.slice((anomalyPage - 1) * PAGE_SIZE, anomalyPage * PAGE_SIZE), [anomalyRows, anomalyPage])
const pagedZero = useMemo(() => zeroRows.slice((zeroPage - 1) * PAGE_SIZE, zeroPage * PAGE_SIZE), [zeroRows, zeroPage])
const pagedCashier = useMemo(() => cashierRows.slice((cashierPage - 1) * PAGE_SIZE, cashierPage * PAGE_SIZE), [cashierRows, cashierPage])
const cashierChartData = [...cashierRows].sort((a: any, b: any) => Number(b.anomaly_bills || 0) - Number(a.anomaly_bills || 0)).slice(0, 15).map((r: any) => ({ ...r, anomaly_bills: Number(r.anomaly_bills || 0) }))
const cashierChartData = cashierRows.slice(0, 15)
if (pageLoading) {
if (initialLoading) {
return <LoadingSpinner text="加载风险数据..." />
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · 20264</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · </p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
@@ -73,16 +96,20 @@ export function RiskPage() {
</CollapsibleSection>
{/* 收银员风险图 */}
<CollapsibleSection title="收银员异常TOP15" subtitle="异常操作率最高的收银员">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={cashierChartData} layout="vertical" margin={{ left: 60 }}>
<CollapsibleSection title="收银员异常账单数TOP15" subtitle="异常账单数最多的收银员(双击下方表格行可查看明细)">
{cashierFetching ? (
<SectionLoading text="加载图表数据..." />
) : (
<ResponsiveContainer width="100%" height={400}>
<BarChart data={cashierChartData} layout="vertical" margin={{ left: 100, right: 40 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" unit="%" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="cashier" tick={{ fontSize: 10 }} width={60} />
<Tooltip formatter={(v: any) => `${Number(v).toFixed(2)}%`} />
<Bar dataKey="anomaly_rate_pct" fill="#ef4444" name="异常" />
<XAxis type="number" tick={{ fontSize: 10 }} domain={[0, 'dataMax']} />
<YAxis type="category" dataKey="cashier" tick={{ fontSize: 11 }} width={90} />
<Tooltip formatter={(v: any) => formatNumber(Number(v))} />
<Bar dataKey="anomaly_bills" fill="#ef4444" name="异常账单数" />
</BarChart>
</ResponsiveContainer>
)}
</CollapsibleSection>
{/* 风险明细表 — Tab切换 */}
@@ -100,67 +127,119 @@ export function RiskPage() {
</div>
{riskTab === 'anomaly' && (
anomalyFetching ? <SectionLoading text="加载异常账单..." /> :
<>
<Pagination page={anomalyPage} pageSize={PAGE_SIZE} total={anomalyRows.length} onPageChange={setAnomalyPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={pagedAnomaly}
/>
</div>
<div className="mb-2 flex gap-4 rounded-md bg-muted/50 px-3 py-2 text-xs">
<span className="text-muted-foreground"></span>
<span> <b className="text-foreground">{formatCurrency(anomalyMeta.sum_consumption)}</b></span>
<span> <b className="text-foreground">{formatCurrency(anomalyMeta.sum_discount)}</b></span>
<span> <b className="text-foreground">{formatCurrency(Number(anomalyMeta.sum_consumption || 0) - Number(anomalyMeta.sum_discount || 0))}</b></span>
<span> <b className="text-foreground">{formatCurrency(anomalyMeta.sum_received)}</b></span>
<span className="text-muted-foreground">| {totalAnomaly} </span>
</div>
<FilterableTable
data={anomalyRows}
serverSide
page={anomalyPage}
onPageChange={setAnomalyPage}
pageSize={ANOMALY_PAGE_SIZE}
total={anomalyMeta.total}
filter={anomalyStore}
onFilterChange={(v) => { setAnomalyStore(v); setAnomalyPage(1) }}
filterKey="store_name"
filterLabel="全部门店"
filterOptions={storeNames}
statusFilterKey="anomaly_reason"
statusFilterLabel="全部异常原因"
statusOptions={[
{ value: '有消费无实收', label: '有消费无实收' },
{ value: '优惠大于消费', label: '优惠大于消费' },
{ value: '消费-优惠与实收不平', label: '消费-优惠与实收不平' },
]}
statusFilterValue={anomalyReason}
onStatusFilterChange={(v) => { setAnomalyReason(v); setAnomalyPage(1) }}
searchPlaceholder="搜索收银员..."
serverSearchValue={anomalyCashier}
onServerSearchChange={(v) => { setAnomalyCashier(v); setAnomalyPage(1) }}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'received_total', label: '实收' },
]}
defaultSort="consumption"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
</>
)}
{riskTab === 'zero' && (
<>
<Pagination page={zeroPage} pageSize={PAGE_SIZE} total={zeroRows.length} onPageChange={setZeroPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'zero_received_type', label: '零实收类型' },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={pagedZero}
/>
</div>
</>
zeroFetching ? <SectionLoading text="加载零实收数据..." /> :
<FilterableTable
data={zeroRows}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="consumption"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'zero_received_type', label: '零实收类型' },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
)}
{riskTab === 'cashier' && (
<>
<Pagination page={cashierPage} pageSize={PAGE_SIZE} total={cashierRows.length} onPageChange={setCashierPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'cashier', label: '收银员' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'anomaly_bills', label: '异常账单数', align: 'right', render: (r) => formatNumber(r.anomaly_bills) },
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => {
const v = Number(r.anomaly_rate_pct || 0)
return <span className={v >= 10 ? 'font-medium text-red-600' : v >= 5 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(2)}%</span>
}},
{ key: 'anomaly_consumption', label: '异常消费额', align: 'right', render: (r) => formatCurrency(r.anomaly_consumption) },
]}
data={pagedCashier}
/>
</div>
</>
cashierFetching ? <SectionLoading text="加载收银员风险..." /> :
<FilterableTable
data={cashierRows}
onRowDoubleClick={(r: any) => {
setAnomalyCashier(r.cashier)
setDebouncedCashier(r.cashier)
setAnomalyPage(1)
setRiskTab('anomaly')
}}
sortOptions={[
{ key: 'anomaly_rate_pct', label: '异常率' },
{ key: 'bill_count', label: '账单数' },
{ key: 'received', label: '实收' },
{ key: 'anomaly_bills', label: '异常账单数' },
{ key: 'cashier', label: '收银员' },
]}
defaultSort="anomaly_bills"
defaultOrder="desc"
columns={[
{ key: 'cashier', label: '收银员' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'anomaly_bills', label: '异常账单数', align: 'right', render: (r) => formatNumber(r.anomaly_bills) },
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => {
const v = Number(r.anomaly_rate_pct || 0)
return <span className={v >= 10 ? 'font-medium text-red-600' : v >= 5 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(2)}%</span>
}},
{ key: 'anomaly_consumption', label: '异常消费额', align: 'right', render: (r) => formatCurrency(r.anomaly_consumption) },
]}
/>
)}
</CollapsibleSection>
</div>
+34 -43
View File
@@ -1,21 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const ABC_COLORS = { 'A-核心': '#22c55e', 'B-成长': '#3b82f6', 'C-长尾': '#ef4444' }
const PAGE_SIZE = 15
export function SKUPage() {
const [abcFilter, setAbcFilter] = useState('')
const [skuPage, setSkuPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: skuData, isLoading: skuLoading } = useQuery({
@@ -28,7 +24,7 @@ export function SKUPage() {
queryFn: () => api.get('/sku/category'),
})
const skuRows = ((skuData as any)?.data || []).filter((r: any) => !abcFilter || r.abc_class === abcFilter)
const skuRows = ((skuData as any)?.data || [])
const catRows = (catData as any)?.data || []
const abcSummary = (skuData as any)?.data?.reduce((acc: any, r: any) => {
@@ -53,8 +49,6 @@ export function SKUPage() {
const lowValueSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.received_amount) < 1000).length
const singleStoreSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.store_count) <= 1).length
const pagedSku = useMemo(() => skuRows.slice((skuPage - 1) * PAGE_SIZE, skuPage * PAGE_SIZE), [skuRows, skuPage])
const pageLoading = skuLoading || catLoading
if (pageLoading) {
@@ -117,40 +111,37 @@ export function SKUPage() {
{/* SKU明细表 */}
<CollapsibleSection title={`SKU明细 (${skuRows.length})`} subtitle="可按ABC分类筛选,点击表头排序">
<div className="mb-3 flex gap-2">
{['', 'A-核心', 'B-成长', 'C-长尾'].map((f) => (
<button
key={f || 'all'}
onClick={() => { setAbcFilter(f); setSkuPage(1) }}
className={`rounded-md border px-3 py-1.5 text-sm ${abcFilter === f ? 'border-primary bg-primary text-primary-foreground' : ''}`}
>
{f || '全部'}
</button>
))}
</div>
<Pagination page={skuPage} pageSize={PAGE_SIZE} total={skuRows.length} onPageChange={setSkuPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'category_level1', label: '一级品类' },
{ key: 'abc_class', label: 'ABC', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
'bg-red-100 text-red-700'
}`}>{r.abc_class}</span>
)},
{ key: 'sales_quadrant', label: '象限' },
{ key: 'store_count', label: '覆盖门店', align: 'center' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
{ key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` },
]}
data={pagedSku}
/>
</div>
<FilterableTable
data={skuRows}
sortOptions={[
{ key: 'received_amount', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'revenue_share_pct', label: '占比' },
{ key: 'store_count', label: '覆盖门店' },
{ key: 'dish_name', label: '菜品名称' },
]}
defaultSort="received_amount"
defaultOrder="desc"
filterKey="abc_class"
filterLabel="全部ABC"
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'category_level1', label: '一级品类' },
{ key: 'abc_class', label: 'ABC', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
'bg-red-100 text-red-700'
}`}>{r.abc_class}</span>
)},
{ key: 'sales_quadrant', label: '象限' },
{ key: 'store_count', label: '覆盖门店', align: 'center' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
{ key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` },
]}
/>
</CollapsibleSection>
{/* 长尾治理建议 */}
+107 -88
View File
@@ -1,18 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, ReferenceLine, Cell } from 'recharts'
import { StoreMap } from '@/components/StoreMap'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
const SCENE_COLORS: Record<string, string> = {
'办公园区': '#3b82f6', '商场商业体': '#a855f7', '社区居民': '#22c55e', '街边综合': '#dc2626', '交通枢纽': '#f97316', '校园档口': '#06b6d4', '特殊业态': '#6b7280',
}
@@ -31,9 +29,6 @@ export function SiteSelectionPage() {
const navigate = useNavigate()
const [tab, setTab] = useState<SiteTab>('benchmark')
const [sceneFilter, setSceneFilter] = useState('')
const [replPage, setReplPage] = useState(1)
const [overlapPage, setOverlapPage] = useState(1)
const [profilePage, setProfilePage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: profileData, isLoading: profileLoading } = useQuery({
@@ -67,10 +62,6 @@ export function SiteSelectionPage() {
const overlapRows = (overlapData as any)?.data || []
const districtRows = (districtData as any)?.data || []
const pagedRepl = useMemo(() => replRows.slice((replPage - 1) * PAGE_SIZE, replPage * PAGE_SIZE), [replRows, replPage])
const pagedOverlap = useMemo(() => overlapRows.slice((overlapPage - 1) * PAGE_SIZE, overlapPage * PAGE_SIZE), [overlapRows, overlapPage])
const pagedProfile = useMemo(() => profileRows.slice((profilePage - 1) * PAGE_SIZE, profilePage * PAGE_SIZE), [profileRows, profilePage])
const pageLoading = profileLoading || segLoading || replLoading || overlapLoading || districtLoading
if (pageLoading) {
@@ -178,7 +169,18 @@ export function SiteSelectionPage() {
{tab === 'benchmark' && (
<div className="space-y-4">
<CollapsibleSection title={`场景 × 面积分段基准 (${segRows.length})`} subtitle="各场景各面积段的经营基准,按坪效降序">
<DataTable
<FilterableTable
data={segRows}
sortOptions={[
{ key: 'avg_received_per_sqm', label: '平均坪效' },
{ key: 'median_received_per_sqm', label: '中位坪效' },
{ key: 'avg_received', label: '平均实收' },
{ key: 'site_scene', label: '场景' },
]}
defaultSort="avg_received_per_sqm"
defaultOrder="desc"
filterKey="site_scene"
filterLabel="全部场景"
columns={[
{ key: 'site_scene', label: '场景' },
{ key: 'area_band', label: '面积段' },
@@ -191,40 +193,36 @@ export function SiteSelectionPage() {
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'avg_repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.avg_repeat_rate_pct) },
]}
data={segRows}
/>
</CollapsibleSection>
<CollapsibleSection title="门店画像明细" subtitle="全部标准门店选址画像,可按场景筛选">
<div className="mb-3 flex gap-2">
{['', '办公园区', '商场商业体', '社区居民', '街边综合'].map((f) => (
<button
key={f || 'all'}
onClick={() => { setSceneFilter(f); setProfilePage(1) }}
className={`rounded-md border px-3 py-1.5 text-sm ${sceneFilter === f ? 'border-primary bg-primary text-primary-foreground' : ''}`}
>
{f || '全部'}
</button>
))}
</div>
<Pagination page={profilePage} pageSize={PAGE_SIZE} total={profileRows.length} onPageChange={setProfilePage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景', render: (r) => <span className="rounded px-2 py-0.5 text-xs" style={{ background: (SCENE_COLORS[r.site_scene] || '#999') + '20', color: SCENE_COLORS[r.site_scene] || '#999' }}>{r.site_scene}</span> },
{ key: 'area_band', label: '面积段' },
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.repeat_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
]}
data={pagedProfile}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
<FilterableTable
data={profileRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'monthly_received_per_sqm', label: '坪效' },
{ key: 'area_sqm', label: '面积' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'repeat_rate_pct', label: '复购率' },
]}
defaultSort="received"
defaultOrder="desc"
filterKey="site_scene"
filterLabel="全部场景"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景', render: (r) => <span className="rounded px-2 py-0.5 text-xs" style={{ background: (SCENE_COLORS[r.site_scene] || '#999') + '20', color: SCENE_COLORS[r.site_scene] || '#999' }}>{r.site_scene}</span> },
{ key: 'area_band', label: '面积段' },
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.repeat_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
]}
/>
</CollapsibleSection>
</div>
)}
@@ -232,32 +230,39 @@ export function SiteSelectionPage() {
{/* 复制评分 */}
{tab === 'replication' && (
<CollapsibleSection title={`门店复制评分 (${replRows.length})`} subtitle="综合坪效、日均、复购、优惠纪律、成本、平台、执行七维评分">
<Pagination page={replPage} pageSize={PAGE_SIZE} total={replRows.length} onPageChange={setReplPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景' },
{ key: 'area_sqm', label: '面积', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'nearest_store_name', label: '最近门店' },
{ key: 'nearest_distance_km', label: '距离(km)', align: 'right', render: (r) => `${r.nearest_distance_km}km` },
{ key: 'site_replication_score', label: '复制评分', align: 'right', render: (r) => {
const v = Number(r.site_replication_score || 0)
return <span className={v >= 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : ''}>{v.toFixed(2)}</span>
} },
{ key: 'replication_recommendation', label: '复制建议', render: (r) => {
const rec = r.replication_recommendation
const cls = rec === '优先提炼选址原型' ? 'bg-green-100 text-green-700' : rec === '不宜作为选址标杆' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
return <span className={`rounded px-2 py-0.5 text-xs ${cls}`}>{rec}</span>
} },
{ key: 'spatial_recommendation', label: '空间建议', render: (r) => <span className="text-xs text-muted-foreground">{r.spatial_recommendation}</span> },
]}
data={pagedRepl}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
<FilterableTable
data={replRows}
sortOptions={[
{ key: 'site_replication_score', label: '复制评分' },
{ key: 'received', label: '实收' },
{ key: 'monthly_received_per_sqm', label: '坪效' },
{ key: 'nearest_distance_km', label: '距离' },
]}
defaultSort="site_replication_score"
defaultOrder="desc"
filterKey="replication_recommendation"
filterLabel="全部建议"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景' },
{ key: 'area_sqm', label: '面积', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'nearest_store_name', label: '最近门店' },
{ key: 'nearest_distance_km', label: '距离(km)', align: 'right', render: (r) => `${r.nearest_distance_km}km` },
{ key: 'site_replication_score', label: '复制评分', align: 'right', render: (r) => {
const v = Number(r.site_replication_score || 0)
return <span className={v >= 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : ''}>{v.toFixed(2)}</span>
} },
{ key: 'replication_recommendation', label: '复制建议', render: (r) => {
const rec = r.replication_recommendation
const cls = rec === '优先提炼选址原型' ? 'bg-green-100 text-green-700' : rec === '不宜作为选址标杆' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
return <span className={`rounded px-2 py-0.5 text-xs ${cls}`}>{rec}</span>
} },
{ key: 'spatial_recommendation', label: '空间建议', render: (r) => <span className="text-xs text-muted-foreground">{r.spatial_recommendation}</span> },
]}
/>
</CollapsibleSection>
)}
@@ -270,24 +275,30 @@ export function SiteSelectionPage() {
<MetricCard title="观察(2-3km)" value={overlapRows.filter((r: any) => r.distance_km >= 2).length} format="number" description="不能仅用直线距离判断" />
</div>
<CollapsibleSection title={`空间重叠风险 (${overlapRows.length}对)`} subtitle="3公里内门店两两距离与重叠风险等级">
<Pagination page={overlapPage} pageSize={PAGE_SIZE} total={overlapRows.length} onPageChange={setOverlapPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name_a', label: '门店A' },
{ key: 'store_name_b', label: '门店B' },
{ key: 'distance_km', label: '距离(km)', align: 'right', render: (r) => `${Number(r.distance_km).toFixed(2)}km` },
{ key: 'received_a', label: 'A实收', align: 'right', render: (r) => formatCurrency(r.received_a) },
{ key: 'received_b', label: 'B实收', align: 'right', render: (r) => formatCurrency(r.received_b) },
{ key: 'overlap_risk', label: '风险等级', render: (r) => {
const risk = r.overlap_risk
const color = RISK_COLORS[risk] || '#999'
return <span className="rounded px-2 py-0.5 text-xs font-medium" style={{ background: color + '20', color }}>{risk}</span>
} },
]}
data={pagedOverlap}
/>
</div>
<FilterableTable
data={overlapRows}
sortOptions={[
{ key: 'distance_km', label: '距离' },
{ key: 'received_a', label: 'A实收' },
{ key: 'received_b', label: 'B实收' },
]}
defaultSort="distance_km"
defaultOrder="asc"
filterKey="overlap_risk"
filterLabel="全部风险"
columns={[
{ key: 'store_name_a', label: '门店A' },
{ key: 'store_name_b', label: '门店B' },
{ key: 'distance_km', label: '距离(km)', align: 'right', render: (r) => `${Number(r.distance_km).toFixed(2)}km` },
{ key: 'received_a', label: 'A实收', align: 'right', render: (r) => formatCurrency(r.received_a) },
{ key: 'received_b', label: 'B实收', align: 'right', render: (r) => formatCurrency(r.received_b) },
{ key: 'overlap_risk', label: '风险等级', render: (r) => {
const risk = r.overlap_risk
const color = RISK_COLORS[risk] || '#999'
return <span className="rounded px-2 py-0.5 text-xs font-medium" style={{ background: color + '20', color }}>{risk}</span>
} },
]}
/>
</CollapsibleSection>
</div>
)}
@@ -308,7 +319,16 @@ export function SiteSelectionPage() {
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title={`区域基准明细 (${districtRows.length})`} subtitle="按区域汇总的经营基准">
<DataTable
<FilterableTable
data={districtRows}
sortOptions={[
{ key: 'avg_received', label: '平均实收' },
{ key: 'avg_received_per_sqm', label: '平均坪效' },
{ key: 'total_received', label: '总实收' },
{ key: 'district', label: '区域' },
]}
defaultSort="avg_received"
defaultOrder="desc"
columns={[
{ key: 'district', label: '区域' },
{ key: 'store_count', label: '门店数', align: 'center' },
@@ -322,7 +342,6 @@ export function SiteSelectionPage() {
{ key: 'p0_count', label: 'P0', align: 'center', render: (r) => r.p0_count > 0 ? <span className="font-bold text-red-600">{r.p0_count}</span> : '0' },
{ key: 'p1_count', label: 'P1', align: 'center', render: (r) => r.p1_count > 0 ? <span className="font-bold text-yellow-600">{r.p1_count}</span> : '0' },
]}
data={districtRows}
/>
</CollapsibleSection>
</div>
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom'
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, Legend, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis } from 'recharts'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
+16 -11
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Tabs } from '@/components/Tabs'
import { MonthPicker } from '@/components/MonthPicker'
import { OverallAnalysisTab } from '@/components/smart-scheduling/OverallAnalysisTab'
import { StaffingForecastTab } from '@/components/smart-scheduling/StaffingForecastTab'
import { TrafficHeatmapTab } from '@/components/smart-scheduling/TrafficHeatmapTab'
@@ -22,25 +23,29 @@ const TABS = [
export function SmartSchedulingPage() {
const [activeTab, setActiveTab] = useState('overall')
const [month, setMonth] = useState('2026-04')
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">20264 · × × · 103</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1"> × × · 103</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div>
{activeTab === 'overall' && <OverallAnalysisTab />}
{activeTab === 'forecast' && <StaffingForecastTab />}
{activeTab === 'traffic' && <TrafficHeatmapTab />}
{activeTab === 'match' && <StaffingMatchTab />}
{activeTab === 'efficiency' && <EfficiencyBenchmarkTab />}
{activeTab === 'suggestion' && <SchedulingSuggestionTab />}
{activeTab === 'alert' && <AttendanceAlertTab />}
{activeTab === 'employee' && <EmployeeAnalysisTab />}
{activeTab === 'overall' && <OverallAnalysisTab month={month} />}
{activeTab === 'forecast' && <StaffingForecastTab month={month} />}
{activeTab === 'traffic' && <TrafficHeatmapTab month={month} />}
{activeTab === 'match' && <StaffingMatchTab month={month} />}
{activeTab === 'efficiency' && <EfficiencyBenchmarkTab month={month} />}
{activeTab === 'suggestion' && <SchedulingSuggestionTab month={month} />}
{activeTab === 'alert' && <AttendanceAlertTab month={month} />}
{activeTab === 'employee' && <EmployeeAnalysisTab month={month} />}
</div>
</div>
)
+76 -57
View File
@@ -4,8 +4,6 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber, cn } from '@/lib/utils'
@@ -40,12 +38,11 @@ export function StoreDetailPage() {
const { code } = useParams()
const navigate = useNavigate()
const [tab, setTab] = useState<DetailTab>('overview')
const [anomalyPage, setAnomalyPage] = useState(1)
const [month, setMonth] = useState('2026-05')
const [month, setMonth] = useState('2026-04')
const { data: storeData } = useQuery({
queryKey: ['store', code],
queryFn: () => api.get(`/stores/${code}`),
queryKey: ['store', code, month],
queryFn: () => api.get(`/stores/${code}`, { params: { month } }),
})
const { data: healthData } = useQuery({
@@ -54,8 +51,8 @@ export function StoreDetailPage() {
})
const { data: dailyData } = useQuery({
queryKey: ['store', code, 'daily'],
queryFn: () => api.get(`/stores/${code}/daily`),
queryKey: ['store', code, 'daily', month],
queryFn: () => api.get(`/stores/${code}/daily`, { params: { month } }),
})
const { data: tasksData } = useQuery({
@@ -65,32 +62,32 @@ export function StoreDetailPage() {
})
const { data: mealData } = useQuery({
queryKey: ['store', code, 'meal-period'],
queryFn: () => api.get(`/stores/${code}/meal-period`),
queryKey: ['store', code, 'meal-period', month],
queryFn: () => api.get(`/stores/${code}/meal-period`, { params: { month } }),
enabled: tab === 'meal',
})
const { data: catData } = useQuery({
queryKey: ['store', code, 'category-mix'],
queryFn: () => api.get(`/stores/${code}/category-mix`),
queryKey: ['store', code, 'category-mix', month],
queryFn: () => api.get(`/stores/${code}/category-mix`, { params: { month } }),
enabled: tab === 'category',
})
const { data: costData } = useQuery({
queryKey: ['store', code, 'cost'],
queryFn: () => api.get(`/stores/${code}/cost`),
queryKey: ['store', code, 'cost', month],
queryFn: () => api.get(`/stores/${code}/cost`, { params: { month } }),
enabled: tab === 'cost',
})
const { data: memberData } = useQuery({
queryKey: ['store', code, 'member'],
queryFn: () => api.get(`/stores/${code}/member`),
queryKey: ['store', code, 'member', month],
queryFn: () => api.get(`/stores/${code}/member`, { params: { month } }),
enabled: tab === 'member',
})
const { data: anomalyData } = useQuery({
queryKey: ['store', code, 'anomalies'],
queryFn: () => api.get(`/stores/${code}/anomalies`, { params: { page: anomalyPage, page_size: 10 } }),
queryKey: ['store', code, 'anomalies', month],
queryFn: () => api.get(`/stores/${code}/anomalies`, { params: { month, page_size: 200 } }),
enabled: tab === 'anomaly',
})
@@ -104,7 +101,7 @@ export function StoreDetailPage() {
const cost = (costData as any)?.data
const member = (memberData as any)?.data
const anomalyRows = ((anomalyData as any)?.data) || []
const anomalyTotal = (anomalyData as any)?.meta?.total || 0
const anomalyTotal = (anomalyData as any)?.meta?.total || anomalyRows.length
if (!sd) return <LoadingSpinner text="加载门店详情..." />
const sc = sd.scorecard
@@ -153,22 +150,24 @@ export function StoreDetailPage() {
<ArrowLeft size={16} />
</button>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">{sc.store_name}</h1>
{health && (
<>
<span className={cn('text-lg font-bold', Number(health.health_score) >= 75 ? 'text-green-600' : Number(health.health_score) >= 55 ? 'text-yellow-600' : 'text-red-600')}>
{health.health_score}
</span>
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
health.health_status === '健康' ? 'bg-green-100 text-green-700 border-green-300' :
health.health_status === '健康' ? 'bg-yellow-100 text-yellow-700 border-yellow-300' :
'bg-red-100 text-red-700 border-red-300')
}>{health.health_status}</span>
</>
)}
{risk?.risk_level && <Badge type="risk" text={risk.risk_level} />}
{quadrant && <span className="rounded-md bg-muted px-2 py-0.5 text-xs">{quadrant}</span>}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">{sc.store_name}</h1>
{health && (
<>
<span className={cn('text-lg font-bold', Number(health.health_score) >= 75 ? 'text-green-600' : Number(health.health_score) >= 55 ? 'text-yellow-600' : 'text-red-600')}>
{health.health_score}
</span>
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
health.health_status === '健康' ? 'bg-green-100 text-green-700 border-green-300' :
health.health_status === '亚健康' ? 'bg-yellow-100 text-yellow-700 border-yellow-300' :
'bg-red-100 text-red-700 border-red-300')
}>{health.health_status}</span>
</>
)}
{risk?.risk_level && <Badge type="risk" text={risk.risk_level} />}
{quadrant && <span className="rounded-md bg-muted px-2 py-0.5 text-xs">{quadrant}</span>}
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -345,7 +344,15 @@ export function StoreDetailPage() {
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
<FilterableTable
data={mealRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'avg_bill', label: '平均单价' },
]}
defaultSort="received"
defaultOrder="desc"
columns={[
{ key: 'meal_period', label: '餐段' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
@@ -354,7 +361,6 @@ export function StoreDetailPage() {
{ key: 'network_avg_bill', label: '全网均价', align: 'right', render: (r) => formatCurrency(r.network_avg_bill) },
{ key: 'vs_network_pct', label: 'vs全网', align: 'right', render: (r) => <span className={Number(r.vs_network_pct) < 0 ? 'text-red-600' : 'text-green-600'}>{Number(r.vs_network_pct).toFixed(1)}%</span> },
]}
data={mealRows}
/>
</div>
</>
@@ -500,23 +506,27 @@ export function StoreDetailPage() {
{tab === 'anomaly' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({anomalyTotal})</h2>
{anomalyTotal > 0 && <Pagination page={anomalyPage} pageSize={10} total={anomalyTotal} onPageChange={setAnomalyPage} />}
<div className="mt-3">
{anomalyRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
columns={[
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={anomalyRows}
/>
)}
</div>
{anomalyRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<FilterableTable
data={anomalyRows}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'received_total', label: '实收' },
]}
defaultSort="consumption"
defaultOrder="desc"
columns={[
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
)}
</div>
)}
@@ -524,7 +534,18 @@ export function StoreDetailPage() {
{tab === 'tasks' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({tasks.length})</h2>
<DataTable
<FilterableTable
data={tasks}
sortOptions={[
{ key: 'priority', label: '优先级' },
{ key: 'deadline', label: '截止日' },
{ key: 'problem_indicator', label: '问题指标' },
]}
defaultSort="priority"
defaultOrder="asc"
filterKey="status"
filterLabel="全部状态"
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
columns={[
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
@@ -532,8 +553,6 @@ export function StoreDetailPage() {
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
]}
data={tasks}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
/>
</div>
)}
+12 -12
View File
@@ -12,37 +12,37 @@ export function StorePage() {
const [executeText, setExecuteText] = useState('')
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
const [storeCode, setStoreCode] = useState('1111')
const [month, setMonth] = useState('2026-05')
const [month, setMonth] = useState('2026-04')
const { data: storesData, isLoading: storesLoading } = useQuery({
queryKey: ['stores-list'],
queryFn: () => api.get('/stores/risk'),
queryKey: ['stores-list', month],
queryFn: () => api.get('/stores/risk', { params: { month } }),
})
const stores = ((storesData as any)?.data || []).map((s: any) => ({ code: s.store_code, name: s.store_name, risk: s.risk_level, issue: s.primary_issue }))
const { data: priorityData } = useQuery({
queryKey: ['stores-priority-detail'],
queryFn: () => api.get('/stores/priority'),
queryKey: ['stores-priority-detail', month],
queryFn: () => api.get('/stores/priority', { params: { month } }),
})
const { data: cardData, isLoading: cardLoading } = useQuery({
queryKey: ['store', storeCode, 'daily-card'],
queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`),
queryKey: ['store', storeCode, 'daily-card', month],
queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`, { params: { month } }),
})
const { data: tasksData, isLoading: tasksLoading } = useQuery({
queryKey: ['store', storeCode, 'tasks'],
queryKey: ['store', storeCode, 'tasks', month],
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month, page_size: 50 } }),
})
const { data: dailyData, isLoading: dailyLoading } = useQuery({
queryKey: ['store', storeCode, 'daily'],
queryKey: ['store', storeCode, 'daily', month],
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month } }),
})
const { data: followupData, isLoading: followupLoading } = useQuery({
queryKey: ['store', storeCode, 'followup'],
queryFn: () => api.get('/tasks/followup'),
queryKey: ['store', storeCode, 'followup', month],
queryFn: () => api.get('/tasks/followup', { params: { month } }),
})
const { data: skuData } = useQuery({
@@ -153,7 +153,7 @@ export function StorePage() {
{/* 经营概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-bold">51</h2>
<h2 className="text-sm font-bold">{month}</h2>
{anomalyCount > 0 && (
<span className="rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">{anomalyCount} </span>
)}
+15 -15
View File
@@ -2,20 +2,16 @@ import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 5
export function TasksPage() {
const navigate = useNavigate()
const [month, setMonth] = useState('2026-05')
const [month, setMonth] = useState('2026-04')
const [priority, setPriority] = useState('')
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const { data } = useQuery({
queryKey: ['tasks', month, priority, status],
@@ -41,7 +37,6 @@ export function TasksPage() {
), [tasks])
const totalGroups = groups.length
const pagedGroups = groups.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return (
<div className="space-y-4">
@@ -80,12 +75,9 @@ export function TasksPage() {
))}
</div>
{/* 分页 */}
<Pagination page={page} pageSize={PAGE_SIZE} total={totalGroups} onPageChange={setPage} />
{/* 任务列表 — 按门店分组 */}
<div className="space-y-3">
{pagedGroups.map((group: any) => (
{groups.map((group: any) => (
<div key={group.store_code} className="rounded-lg border bg-card overflow-hidden">
{/* 门店组标题 */}
<div
@@ -97,7 +89,18 @@ export function TasksPage() {
<span className="text-xs text-muted-foreground">{group.tasks.length} </span>
</div>
{/* 组内任务表 */}
<DataTable
<FilterableTable
data={group.tasks}
sortOptions={[
{ key: 'priority', label: '优先级' },
{ key: 'deadline', label: '截止日' },
{ key: 'problem_indicator', label: '问题指标' },
]}
defaultSort="priority"
defaultOrder="asc"
filterKey="status"
filterLabel="全部状态"
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
columns={[
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'action_required', label: '行动要求', render: (r) => <span className="text-xs">{r.action_required}</span> },
@@ -105,9 +108,6 @@ export function TasksPage() {
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
]}
data={group.tasks}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
className="rounded-none border-0"
/>
</div>
))}
+20 -13
View File
@@ -5,6 +5,8 @@ import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Area, AreaChart } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
import { useState } from 'react'
const CHANNEL_COLORS: Record<string, string> = {
cash: '#22c55e',
@@ -38,19 +40,21 @@ const CHANNEL_LABELS: Record<string, string> = {
const channelLabel = (k: string) => CHANNEL_LABELS[k] || k
export function TimePage() {
const [month, setMonth] = useState('2026-04')
const { data: weekdayData, isLoading: weekdayLoading } = useQuery({
queryKey: ['time/weekday'],
queryFn: () => api.get('/time/weekday'),
queryKey: ['time/weekday', month],
queryFn: () => api.get('/time/weekday', { params: { month } }),
})
const { data: hourlyData, isLoading: hourlyLoading } = useQuery({
queryKey: ['time/hourly'],
queryFn: () => api.get('/time/hourly'),
queryKey: ['time/hourly', month],
queryFn: () => api.get('/time/hourly', { params: { month } }),
})
const { data: channelData, isLoading: channelLoading } = useQuery({
queryKey: ['channel'],
queryFn: () => api.get('/channel'),
queryKey: ['channel', month],
queryFn: () => api.get('/channel', { params: { month } }),
})
const weekdayRows = (weekdayData as any)?.data || []
@@ -90,17 +94,20 @@ export function TimePage() {
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · 20264</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · </p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="时间概览" subtitle="高峰与低谷识别">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="高峰日" value={peakWeekday?.weekday || '-'} description={`实收最高: ${formatCurrency(peakWeekday?.received)}`} />
<MetricCard title="低谷日" value={lowWeekday?.weekday || '-'} description={`实收最低: ${formatCurrency(lowWeekday?.received)}`} />
<MetricCard title="高峰时段" value={peakHour ? `${peakHour.closing_hour}:00` : '-'} description={`账单数最多: ${formatNumber(peakHour?.bill_count)}`} />
<MetricCard title="高峰日" value={peakWeekday?.weekday || '-'} format="text" description={`实收最高: ${formatCurrency(peakWeekday?.received)}`} />
<MetricCard title="低谷日" value={lowWeekday?.weekday || '-'} format="text" description={`实收最低: ${formatCurrency(lowWeekday?.received)}`} />
<MetricCard title="高峰时段" value={peakHour ? `${peakHour.closing_hour}:00` : '-'} format="text" description={`账单数最多: ${formatNumber(peakHour?.bill_count)}`} />
<MetricCard title="渠道数" value={channelTotals.length} format="number" description="有交易记录的支付渠道数量" />
</div>
</CollapsibleSection>
@@ -117,7 +124,7 @@ export function TimePage() {
<Legend />
<Bar yAxisId="left" dataKey="received" fill="#3b82f6" name="实收" />
<Bar yAxisId="right" dataKey="bill_count" fill="#22c55e" name="账单数" />
<Bar yAxisId="left" dataKey="avg_bill_value" fill="#eab308" name="客单价" />
<Line yAxisId="left" type="monotone" dataKey="avg_bill_value" stroke="#eab308" name="客单价" strokeWidth={2} />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
+103 -55
View File
@@ -10,6 +10,7 @@ const router = Router()
// 成本总览指标
router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
count(*) AS total_dishes,
@@ -22,7 +23,8 @@ router.get('/overview', async (req: AuthRequest, res) => {
round(sum(actual_cost)::numeric, 2) AS total_actual_cost,
round(sum(cost_variance_amount)::numeric, 2) AS total_variance
FROM public.dish_cost_analysis_summary
`)
WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
@@ -32,6 +34,7 @@ router.get('/overview', async (req: AuthRequest, res) => {
// 品类成本对比
router.get('/category-comparison', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT category_level1,
count(*) AS dishes,
@@ -43,8 +46,9 @@ router.get('/category-comparison', async (req: AuthRequest, res) => {
round(sum(cost_variance_amount)::numeric, 2) AS total_variance
FROM public.dish_cost_analysis_summary
WHERE category_level1 IS NOT NULL
AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY category_level1 ORDER BY total_sales DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -54,6 +58,7 @@ router.get('/category-comparison', async (req: AuthRequest, res) => {
// 毛利率偏差分布
router.get('/margin-deviation', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
CASE
@@ -66,8 +71,9 @@ router.get('/margin-deviation', async (req: AuthRequest, res) => {
count(*) AS cnt
FROM public.dish_cost_analysis_summary
WHERE theoretical_margin_rate_pct IS NOT NULL AND actual_margin_rate_pct IS NOT NULL
AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY deviation_band ORDER BY cnt DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -79,6 +85,7 @@ router.get('/variance-top', async (req: AuthRequest, res) => {
try {
const limit = parseInt((req.query.limit as string) || '50')
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const month = parseMonth(req)
const result = await query(`
SELECT dish_name, dish_code, category_level1,
round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
@@ -93,8 +100,9 @@ router.get('/variance-top', async (req: AuthRequest, res) => {
ELSE '正常'
END AS cost_tier
FROM public.dish_cost_analysis_summary
WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1)
ORDER BY cost_variance_amount ${order} LIMIT $1
`, [limit])
`, [limit, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -106,24 +114,26 @@ router.get('/variance-top', async (req: AuthRequest, res) => {
// 菜单工程矩阵
router.get('/menu-engineering', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH imp AS (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
SELECT dish_name, dish_code, category_level1,
round(sales_quantity::numeric, 2) AS sales_quantity,
round(sales_amount::numeric, 2) AS sales_amount,
round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
CASE
WHEN actual_margin_rate_pct < 0 THEN '数据异常'
WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '明星盈利品'
WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '高销低利品'
WHEN sales_quantity < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '低销高利品'
WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '明星盈利品'
WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id)
AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '高销低利品'
WHEN sales_quantity < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '低销高利品'
ELSE '低销低利品'
END AS menu_type
FROM public.dish_cost_analysis_summary
WHERE actual_margin_rate_pct IS NOT NULL
`)
FROM public.dish_cost_analysis_summary s, imp
WHERE s.import_id = imp.import_id AND actual_margin_rate_pct IS NOT NULL
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -141,8 +151,9 @@ router.get('/profitability', async (req: AuthRequest, res) => {
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[] = []
const month = parseMonth(req)
let where = 'WHERE actual_margin_rate_pct IS NOT NULL AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)'
const params: any[] = [month]
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` }
@@ -177,6 +188,7 @@ router.get('/profitability', async (req: AuthRequest, res) => {
router.get('/pricing', async (req: AuthRequest, res) => {
try {
const threshold = parseFloat((req.query.threshold as string) || '50')
const month = parseMonth(req)
const result = await query(`
SELECT dish_name, dish_code, category_level1,
round(price::numeric, 2) AS price,
@@ -187,8 +199,9 @@ router.get('/pricing', async (req: AuthRequest, res) => {
round(sales_amount::numeric, 2) AS sales_amount
FROM public.dish_cost_analysis_summary
WHERE theoretical_margin_rate_pct < $1 AND sales_amount > 1000
AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1)
ORDER BY theoretical_margin_rate_pct ASC
`, [threshold])
`, [threshold, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -202,6 +215,7 @@ router.get('/material-variance', async (req: AuthRequest, res) => {
try {
const dishCode = req.query.dish_code as string
if (!dishCode) return sendError(res, 'dish_code is required')
const month = parseMonth(req)
const result = await query(`
SELECT m.material_name, m.material_unit,
@@ -221,8 +235,9 @@ router.get('/material-variance', async (req: AuthRequest, res) => {
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.dish_code = $1
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1)
ORDER BY m.loss_amount ASC
`, [dishCode])
`, [dishCode, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -232,6 +247,7 @@ router.get('/material-variance', async (req: AuthRequest, res) => {
// 损耗率分布
router.get('/loss-distribution', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
CASE
@@ -244,9 +260,11 @@ router.get('/loss-distribution', async (req: AuthRequest, res) => {
ELSE '正向(>0%)'
END AS loss_band,
count(*) AS cnt
FROM public.dish_cost_analysis_material_detail
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY loss_band ORDER BY cnt DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -258,6 +276,7 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => {
try {
const limit = parseInt((req.query.limit as string) || '50')
const order = (req.query.order as string) === 'rate' ? 'avg_loss_rate' : 'total_loss_qty'
const month = parseMonth(req)
const result = await query(`
SELECT material_name, material_type,
count(DISTINCT m.summary_id) AS dish_count,
@@ -266,10 +285,12 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => {
round(max(loss_quantity_rate_pct)::numeric, 2) AS max_loss_rate,
round(sum(loss_amount)::numeric, 2) AS total_loss_amount
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE m.loss_quantity < 0
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1)
GROUP BY material_name, material_type
ORDER BY ${order} ASC LIMIT $1
`, [limit])
`, [limit, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -279,15 +300,18 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => {
// 物料类型损耗对比
router.get('/material-type-loss', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT material_type,
count(*) AS cnt,
round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
round(sum(loss_amount)::numeric, 2) AS total_loss_amount,
count(DISTINCT material_name) AS unique_materials
FROM public.dish_cost_analysis_material_detail
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY material_type ORDER BY total_loss_amount ASC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -582,15 +606,18 @@ router.get('/material-demand', async (req: AuthRequest, res) => {
// 包装总览
router.get('/packaging-overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
count(DISTINCT material_name) AS packaging_types,
round(sum(theoretical_amount)::numeric, 2) AS total_cost,
round(sum(loss_quantity)::numeric, 2) AS total_loss_qty,
count(*) FILTER (WHERE loss_quantity_rate_pct < -20) AS high_loss_count
FROM public.dish_cost_analysis_material_detail
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE material_name ~* '餐盒|餐具|打包|纸巾|餐盒|调味包|餐盒|碗|袋|杯|盒'
`)
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
@@ -612,30 +639,35 @@ router.get('/packaging-detail', async (req: AuthRequest, res) => {
const validSorts = ['total_cost', 'total_qty', 'loss_qty', 'avg_loss_rate', 'dish_count', 'material_name']
const sortCol = validSorts.includes(sort) ? sort : 'total_cost'
const month = parseMonth(req)
const countResult = await query(`
SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail
SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
`)
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
const total = countResult.rows[0].count
const result = await query(`
SELECT material_name,
count(DISTINCT summary_id) AS dish_count,
round(sum(theoretical_quantity)::numeric, 2) AS total_qty,
round(sum(theoretical_amount)::numeric, 2) AS total_cost,
round(sum(loss_quantity)::numeric, 2) AS loss_qty,
round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
count(DISTINCT m.summary_id) AS dish_count,
round(sum(m.theoretical_quantity)::numeric, 2) AS total_qty,
round(sum(m.theoretical_amount)::numeric, 2) AS total_cost,
round(sum(m.loss_quantity)::numeric, 2) AS loss_qty,
round((sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
CASE
WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -20 THEN '核查'
WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -5 THEN '关注'
WHEN sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100 < -20 THEN '核查'
WHEN sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100 < -5 THEN '关注'
ELSE '正常'
END AS status
FROM public.dish_cost_analysis_material_detail
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY material_name
${having}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3
`, [month, pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
@@ -647,19 +679,20 @@ router.get('/packaging-detail', async (req: AuthRequest, res) => {
// 数据质量总览
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
(SELECT count(*) FROM analytics.dim_sku) AS total_sku,
(SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_with_bom,
round((SELECT count(DISTINCT sku_code)::numeric FROM analytics.fact_recipe_bom) / nullif((SELECT count(*) FROM analytics.dim_sku), 0) * 100, 2) AS bom_coverage,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = true) AS matched_dishes,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary) AS total_dishes,
(SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)) AS matched_dishes,
(SELECT count(*) FROM public.dish_cost_analysis_summary WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)) AS total_dishes,
(SELECT count(*) FROM analytics.fact_recipe_bom WHERE standard_net_quantity = 0) AS zero_actual_bom,
(SELECT count(*) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct < 0) AS negative_margin_count,
(SELECT count(*) FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct < 0) AS negative_theo_margin_count,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = true) AS matched_materials,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail) AS total_materials
`)
(SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND s.actual_margin_rate_pct < 0) AS negative_margin_count,
(SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND s.theoretical_margin_rate_pct < 0) AS negative_theo_margin_count,
(SELECT count(*) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name)) AS matched_materials,
(SELECT count(*) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)) AS total_materials
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
@@ -676,16 +709,17 @@ router.get('/unmatched-dishes', async (req: AuthRequest, res) => {
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 month = parseMonth(req)
const countResult = await query(`SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)`, [month])
const total = countResult.rows[0].count
const result = await query(`
SELECT dish_name, dish_code, category_level1,
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 ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
FROM public.dish_cost_analysis_summary s
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3
`, [month, pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
@@ -702,18 +736,30 @@ router.get('/unmatched-materials', async (req: AuthRequest, res) => {
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 month = parseMonth(req)
const countResult = await query(`
SELECT count(*) FROM (
SELECT material_name, material_type
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
AND NOT EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name)
GROUP BY material_name, material_type
) t
`, [month])
const total = countResult.rows[0].count
const result = await query(`
SELECT material_name, material_type,
count(DISTINCT summary_id) AS dish_count,
count(DISTINCT m.summary_id) AS dish_count,
round(sum(loss_amount)::numeric, 2) AS loss_amount
FROM analytics.v_dish_cost_analysis_latest_material_detail
WHERE matched_to_inventory_by_name = false
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
AND NOT EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name)
GROUP BY material_name, material_type
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3
`, [month, pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
@@ -808,6 +854,7 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
// 散点图数据
router.get('/scatter', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT dish_name, dish_code, category_level1,
round(sales_quantity::numeric, 2) AS x,
@@ -815,7 +862,8 @@ router.get('/scatter', async (req: AuthRequest, res) => {
round(sales_amount::numeric, 2) AS size
FROM public.dish_cost_analysis_summary
WHERE cost_variance_amount IS NOT NULL AND sales_quantity IS NOT NULL
`)
AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
+341 -72
View File
@@ -80,10 +80,12 @@ router.get('/stores/risk', async (req: AuthRequest, res) => {
router.get('/stores/priority', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT store_code, store_name, action_priority, problem_count, problem_combination,
received, scale_tier, business_type
FROM analytics.cache_store_priority
FROM analytics.mv_store_action_priority_deep_monthly
WHERE month_start = $1
ORDER BY CASE action_priority
WHEN 'P0-修复数据口径' THEN 1
WHEN 'P0-综合专项整改' THEN 2
@@ -92,7 +94,7 @@ router.get('/stores/priority', async (req: AuthRequest, res) => {
WHEN '标杆候选' THEN 5
ELSE 6
END, received DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -101,7 +103,8 @@ router.get('/stores/priority', async (req: AuthRequest, res) => {
router.get('/stores/quadrant', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_benchmark ORDER BY management_quadrant, avg_daily_received DESC`)
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $1 ORDER BY benchmark_score DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -111,16 +114,43 @@ router.get('/stores/quadrant', async (req: AuthRequest, res) => {
router.get('/stores/:code', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const [scorecard, risk, platform, benchmark, action] = await Promise.all([
query(`SELECT * FROM analytics.mv_store_scorecard WHERE store_code = $1`, [code]),
query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [parseMonth(req), code]),
query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, parseMonth(req)]),
query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $2 AND store_code = $1`, [code, parseMonth(req)]),
query(`SELECT * FROM analytics.mv_store_action_priority_deep_monthly WHERE month_start = $1 AND store_code = $2`, [parseMonth(req), code]),
query(`
SELECT store_code, store_name,
count(*) AS bill_count,
count(DISTINCT closed_at::date) AS active_days,
sum(received_total) AS received,
round(sum(received_total) / NULLIF(count(DISTINCT closed_at::date), 0), 2) AS avg_daily_received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(received_total) / NULLIF(sum(guest_count), 0), 2) AS avg_guest_value,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct,
round(count(*) FILTER (WHERE member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
FROM analytics.fact_bill
WHERE store_code = $1
AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')
GROUP BY store_code, store_name
`, [code, month]),
query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
query(`SELECT * FROM analytics.mv_store_action_priority_deep_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
])
if (scorecard.rows.length === 0) {
return sendError(res, 'Store not found', 404)
const storeInfo = await query(`SELECT store_code, store_name FROM analytics.dim_store WHERE store_code = $1`, [code])
if (storeInfo.rows.length === 0) {
return sendError(res, 'Store not found', 404)
}
const si = storeInfo.rows[0] as any
return sendSuccess(res, {
scorecard: { store_code: si.store_code, store_name: si.store_name, bill_count: 0, active_days: 0, received: 0, avg_daily_received: 0, avg_bill_value: 0, avg_guest_value: 0, discount_rate_pct: 0, theoretical_margin_pct: 0, member_bill_share_pct: 0 },
risk: null,
platform: null,
benchmark: null,
action: { management_quadrant: '问题门店', risk_level: null },
})
}
const sc = scorecard.rows[0] as any
@@ -159,7 +189,7 @@ router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
FROM analytics.bill_fact
FROM analytics.fact_bill
WHERE store_code = $1
AND closed_at >= $2::date
AND closed_at < ($2::date + interval '1 month')
@@ -176,7 +206,7 @@ router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
router.get('/cost/comparison', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ORDER BY variance_to_theoretical_pct DESC NULLS LAST`, [month])
const result = await query(`SELECT store_code, store_name, bill_count, sales_consumption, sales_received, theoretical_cost, theoretical_profit, actual_food_cost, actual_total_cost, packaging_cost, operating_supply_cost, repair_equipment_cost, new_retail_cost, ending_inventory_amount, negative_item_lines, negative_consumption_amount, food_cost_variance, theoretical_cost_rate_pct, actual_food_cost_rate_pct, variance_to_theoretical_pct, cost_rate_gap_pct, comparison_status, variance_level FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ORDER BY variance_to_theoretical_pct DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -186,7 +216,35 @@ router.get('/cost/comparison', async (req: AuthRequest, res) => {
router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_inventory_cost_classified_monthly WHERE month_start = $1 ORDER BY sales_store_code`, [month])
const result = await query(`
WITH store_cat AS (
SELECT sales_store_name AS store_name, finance_category,
round(sum(consumption_amount)::numeric, 2) AS actual_category_cost
FROM analytics.mv_inventory_cost_classified_monthly WHERE month_start = $1
GROUP BY sales_store_name, finance_category
),
store_sales AS (
SELECT store_name, max(sales_received) AS sales_received
FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1
GROUP BY store_name
),
cat_sales AS (
SELECT sc.store_name, sc.finance_category, sc.actual_category_cost,
CASE WHEN ss.sales_received > 0 THEN round(sc.actual_category_cost / ss.sales_received * 10000, 2) ELSE NULL END AS cost_per_10k_sales
FROM store_cat sc JOIN store_sales ss ON sc.store_name = ss.store_name
),
peer AS (
SELECT finance_category, percentile_cont(0.5) WITHIN GROUP (ORDER BY cost_per_10k_sales) AS peer_median
FROM cat_sales WHERE cost_per_10k_sales IS NOT NULL GROUP BY finance_category
)
SELECT cs.store_name, cs.finance_category, cs.actual_category_cost,
cs.cost_per_10k_sales,
round(pm.peer_median::numeric, 2) AS peer_median_cost_per_10k,
CASE WHEN cs.cost_per_10k_sales IS NOT NULL AND pm.peer_median IS NOT NULL
THEN round((cs.cost_per_10k_sales - pm.peer_median)::numeric, 2) ELSE NULL END AS excess_vs_peer_per_10k
FROM cat_sales cs LEFT JOIN peer pm ON cs.finance_category = pm.finance_category
ORDER BY cs.store_name
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -196,7 +254,15 @@ router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
router.get('/cost/inventory', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_area_efficiency_monthly WHERE month_start = $1 ORDER BY estimated_inventory_days DESC NULLS LAST`, [month])
const result = await query(`
SELECT a.store_code, a.store_name, a.business_type, a.scale_tier, a.estimated_inventory_days,
t.ending_inventory_amount, t.negative_item_lines
FROM analytics.mv_store_area_efficiency_monthly a
LEFT JOIN analytics.mv_store_theoretical_actual_cost_monthly t
ON a.store_code = t.store_code AND a.month_start = t.month_start
WHERE a.month_start = $1
ORDER BY a.estimated_inventory_days DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -215,7 +281,21 @@ router.get('/platform/economics', async (req: AuthRequest, res) => {
router.get('/member/comparison', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_member_comparison`)
const month = parseMonth(req)
const result = await query(`
SELECT
CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END AS customer_type,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(avg(received_total), 2) AS avg_bill_value,
round(avg(discount_total), 2) AS avg_discount,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END
ORDER BY customer_type
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -225,7 +305,7 @@ router.get('/member/comparison', async (req: AuthRequest, res) => {
router.get('/member/repeat', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_repeat_summary_monthly WHERE month_start = $1::date ORDER BY repeat_rate_pct DESC NULLS LAST`, [month])
const result = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly WHERE month_start = $1::date ORDER BY repeat_rate_pct DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -261,12 +341,57 @@ router.get('/sku/attach', async (req: AuthRequest, res) => {
}
})
const RECEIVED_COLS = `COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)`
router.get('/risk/anomaly', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills`)
const result = await query(`SELECT * FROM analytics.v_anomaly_bills ORDER BY closed_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
const month = parseMonth(req)
const storeName = req.query.store as string
const reason = req.query.reason as string
const cashier = req.query.cashier as string
const conditions: string[] = [
`c175 IS NOT NULL AND c175 != ''`,
`c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')`,
`((c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) OR (c068::numeric > c009::numeric) OR (abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05))`,
]
const params: any[] = [month]
let paramIdx = 2
if (storeName) {
conditions.push(`c003 = $${paramIdx}`)
params.push(storeName)
paramIdx++
}
if (reason) {
if (reason === '有消费无实收') conditions.push(`c009::numeric > 0 AND (${RECEIVED_COLS}) = 0`)
else if (reason === '优惠大于消费') conditions.push(`c068::numeric > c009::numeric`)
else if (reason === '消费-优惠与实收不平') conditions.push(`abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 AND NOT (c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AND NOT (c068::numeric > c009::numeric)`)
}
if (cashier) {
conditions.push(`c191 ILIKE $${paramIdx}`)
params.push(`%${cashier}%`)
paramIdx++
}
const whereClause = conditions.join(' AND ')
const countResult = await query(`SELECT count(*) AS total, round(sum(c009::numeric),2) AS sum_consumption, round(sum(COALESCE(c068::numeric,0)),2) AS sum_discount, round(sum(${RECEIVED_COLS}),2) AS sum_received FROM bill_records WHERE ${whereClause}`, params)
const result = await query(`
SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
(${RECEIVED_COLS}) AS received_total,
c191 AS cashier, c176 AS closed_at,
CASE
WHEN c009::numeric > 0 AND (${RECEIVED_COLS}) = 0 THEN '有消费无实收'
WHEN c068::numeric > c009::numeric THEN '优惠大于消费'
WHEN abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 THEN '消费-优惠与实收不平'
END AS anomaly_reason
FROM bill_records
WHERE ${whereClause}
ORDER BY c176 DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`, [...params, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize, sum_consumption: countResult.rows[0].sum_consumption, sum_discount: countResult.rows[0].sum_discount, sum_received: countResult.rows[0].sum_received })
} catch (err: any) {
sendError(res, err.message)
}
@@ -274,14 +399,29 @@ router.get('/risk/anomaly', async (req: AuthRequest, res) => {
router.get('/risk/zero-received', async (req: AuthRequest, res) => {
try {
const storeCode = req.query.store_code as string
let sql = `SELECT * FROM analytics.v_zero_received_detail`
const params: any[] = []
if (storeCode) {
sql += ` WHERE store_code = $1`
params.push(storeCode)
const month = parseMonth(req)
const storeName = req.query.store as string
let sql = `
SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
0 AS received_total,
c191 AS cashier, c176 AS closed_at,
CASE
WHEN c068::numeric >= c009::numeric AND c009::numeric > 0 THEN '全额优惠'
WHEN c009::numeric = 0 OR c009 IS NULL OR c009 = '' THEN '零消费零实收'
ELSE '有消费无实收'
END AS zero_received_type
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
AND c009::numeric > 0 AND (${RECEIVED_COLS}) = 0
`
const params: any[] = [month]
if (storeName) {
sql += ` AND c003 = $2`
params.push(storeName)
}
sql += ` ORDER BY closed_at DESC LIMIT 200`
sql += ` ORDER BY c009::numeric DESC LIMIT 200`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -291,7 +431,21 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => {
router.get('/risk/cashier', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_cashier_risk ORDER BY anomaly_rate_pct DESC NULLS LAST`)
const month = parseMonth(req)
const result = await query(`
SELECT c003 AS store_name, c191 AS cashier,
count(*) AS bill_count,
round(sum(${RECEIVED_COLS})::numeric, 2) AS received,
count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AS anomaly_bills,
round(count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric / count(*) * 100, 2) AS anomaly_rate_pct,
round(sum(c009::numeric) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric, 2) AS anomaly_consumption
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
AND c191 IS NOT NULL AND c191 != ''
GROUP BY c003, c191
ORDER BY anomaly_rate_pct DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -300,7 +454,21 @@ router.get('/risk/cashier', async (req: AuthRequest, res) => {
router.get('/marketing/plans', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_marketing_plan_summary ORDER BY received DESC`)
const month = parseMonth(req)
const result = await query(`
SELECT store_code, store_name,
count(*) AS bill_count,
sum(received_total) AS received,
sum(discount_total) AS discount_amount,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(third_party_discount) / NULLIF(sum(received_total), 0) * 100, 2) AS third_party_rate_pct,
count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '') AS plan_bills,
round(count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '')::numeric / count(*) * 100, 2) AS plan_coverage_pct
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY store_code, store_name
ORDER BY received DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -319,7 +487,21 @@ router.get('/benchmark/composite', async (req: AuthRequest, res) => {
router.get('/time/weekday', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_weekday_summary ORDER BY weekday_no`)
const month = parseMonth(req)
const result = await query(`
SELECT EXTRACT(isodow FROM closed_at)::int AS weekday_no,
CASE EXTRACT(isodow FROM closed_at)::int
WHEN 1 THEN '周一' WHEN 2 THEN '周二' WHEN 3 THEN '周三'
WHEN 4 THEN '周四' WHEN 5 THEN '周五' WHEN 6 THEN '周六' ELSE '周日'
END AS weekday,
count(*) AS bill_count,
sum(received_total) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(avg(duration_minutes), 2) AS avg_duration_minutes
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1, 2 ORDER BY 1
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -328,7 +510,18 @@ router.get('/time/weekday', async (req: AuthRequest, res) => {
router.get('/time/hourly', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_hourly_summary ORDER BY closing_hour`)
const month = parseMonth(req)
const result = await query(`
SELECT EXTRACT(hour FROM closed_at)::int AS closing_hour,
count(*) AS bill_count,
sum(received_total) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(avg(duration_minutes), 2) AS avg_duration_minutes
FROM analytics.fact_bill
WHERE closed_at IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1 ORDER BY 1
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -337,7 +530,23 @@ router.get('/time/hourly', async (req: AuthRequest, res) => {
router.get('/channel', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_channel_daily ORDER BY business_date`)
const month = parseMonth(req)
const result = await query(`
SELECT closed_at::date AS business_date,
sum(cash_received) AS cash,
sum(alipay_received) AS alipay,
sum(wechat_received) AS wechat,
sum(meituan_received) AS meituan,
sum(unionpay_received) AS unionpay,
sum(douyin_received) AS douyin,
sum(credit_received) AS credit,
sum(jd_delivery_received) AS jd_delivery,
sum(meituan_delivery_received) AS meituan_delivery,
sum(taobao_delivery_received) AS taobao_delivery
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1 ORDER BY 1
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -358,7 +567,7 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
count(DISTINCT store_code) AS store_count,
min(closed_at)::text AS min_date,
max(closed_at)::text AS max_date
FROM analytics.bill_fact
FROM analytics.fact_bill
`)
const dishStats = await query(`
SELECT
@@ -388,7 +597,39 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_meal_opportunity WHERE store_code = $1 ORDER BY bill_count DESC`, [req.params.code])
const month = parseMonth(req)
const code = req.params.code
const result = await query(`
WITH network AS (
SELECT meal_period,
count(*) AS network_bills,
(sum(received_total) / NULLIF(count(*), 0)) AS network_avg_bill
FROM analytics.fact_bill
WHERE meal_period IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY meal_period
), store_meal AS (
SELECT store_code, store_name, meal_period,
count(*) AS bill_count,
sum(received_total) AS received,
(sum(received_total) / NULLIF(count(*), 0)) AS avg_bill
FROM analytics.fact_bill
WHERE meal_period IS NOT NULL
AND store_code = $2
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY store_code, store_name, meal_period
)
SELECT s.store_code, s.store_name, s.meal_period,
s.bill_count,
round(s.received, 2) AS received,
round(s.avg_bill, 2) AS avg_bill,
round(n.network_avg_bill, 2) AS network_avg_bill,
round(GREATEST(n.network_avg_bill - s.avg_bill, 0) * s.bill_count, 2) AS avg_bill_uplift_scenario,
round((s.avg_bill / n.network_avg_bill - 1) * 100, 2) AS vs_network_pct
FROM store_meal s
JOIN network n ON s.meal_period = n.meal_period
ORDER BY s.bill_count DESC
`, [month, code])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -469,16 +710,38 @@ router.get('/stores/:code/member', async (req: AuthRequest, res) => {
router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills WHERE store_code = $1`, [req.params.code])
const result = await query(`SELECT * FROM analytics.v_anomaly_bills WHERE store_code = $1 ORDER BY closed_at DESC LIMIT $2 OFFSET $3`, [req.params.code, pageSize, offset])
const month = parseMonth(req)
const code = req.params.code
const countResult = await query(`SELECT count(*) AS total FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')`, [code, month])
const result = await query(`SELECT * FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') ORDER BY closed_at DESC LIMIT $3 OFFSET $4`, [code, month, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) { sendError(res, err.message) }
})
// 区域汇总
router.get('/region/summary', async (req, res) => {
router.get('/region/summary', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.mv_region_summary ORDER BY total_received DESC`)
const month = parseMonth(req)
const result = await query(`
SELECT d.region,
count(DISTINCT b.store_code) AS store_count,
COALESCE(sum(b.received_total), 0) AS total_received,
count(*) AS total_bill_count,
round(avg(b.received_total), 2) AS avg_bill_value,
round(sum(b.discount_total) / NULLIF(sum(b.consumption), 0) * 100, 2) AS avg_discount_rate,
round(sum(b.theoretical_profit) / NULLIF(sum(b.received_total), 0) * 100, 2) AS avg_margin_rate,
0 AS avg_anomaly_rate,
count(DISTINCT CASE WHEN r.risk_level = '红色' THEN b.store_code END) AS red_count,
count(DISTINCT CASE WHEN r.risk_level = '黄色' THEN b.store_code END) AS yellow_count,
count(DISTINCT CASE WHEN r.risk_level = '绿色' THEN b.store_code END) AS green_count
FROM analytics.fact_bill b
JOIN analytics.dim_store d ON d.store_code = b.store_code
LEFT JOIN analytics.mv_store_risk_rating_monthly r ON r.store_code = b.store_code AND r.month_start = $1::date
WHERE d.region IS NOT NULL AND d.region <> ''
AND b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month')
GROUP BY d.region
ORDER BY total_received DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1112,10 +1375,12 @@ router.get('/bank/report', async (req: AuthRequest, res) => {
const stdDev = Math.sqrt(variance)
const cv = meanRev > 0 ? stdDev / meanRev : 0
const f = (v: any) => v === null || v === undefined ? 0 : parseFloat(v)
const i = (v: any) => v === null || v === undefined ? 0 : parseInt(v)
sendSuccess(res, {
overview: { ...ov, received: parseFloat(ov?.received), bill_count: parseInt(ov?.bill_count), avg_bill_value: parseFloat(ov?.avg_bill_value), discount_rate_pct: parseFloat(ov?.discount_rate_pct), theoretical_margin_pct: parseFloat(ov?.theoretical_margin_pct), member_bills: parseInt(ov?.member_bills), member_share_pct: parseFloat(ov?.member_share_pct) },
overview: { ...ov, received: f(ov?.received), bill_count: i(ov?.bill_count), avg_bill_value: f(ov?.avg_bill_value), discount_rate_pct: f(ov?.discount_rate_pct), theoretical_margin_pct: f(ov?.theoretical_margin_pct), member_bills: i(ov?.member_bills), member_share_pct: f(ov?.member_share_pct) },
daily: dailyRows,
waterfall: { ...wf, received: parseFloat(wf?.received), food_cost: parseFloat(wf?.food_cost), wage: parseFloat(wf?.wage), rent: parseFloat(wf?.rent), utility: parseFloat(wf?.utility), dorm: parseFloat(wf?.dorm), commission: parseFloat(wf?.commission), other_expense: parseFloat(wf?.other_expense), total_expense: parseFloat(wf?.total_expense), store_contribution: parseFloat(wf?.store_contribution) },
waterfall: { ...wf, received: f(wf?.received), food_cost: f(wf?.food_cost), wage: f(wf?.wage), rent: f(wf?.rent), utility: f(wf?.utility), dorm: f(wf?.dorm), commission: f(wf?.commission), other_expense: f(wf?.other_expense), total_expense: f(wf?.total_expense), store_contribution: f(wf?.store_contribution) },
risk: riskRows,
channel: channelTotals,
stores: storeRows,
@@ -1739,8 +2004,10 @@ router.get('/central-kitchen/bom-penetration', async (req: AuthRequest, res) =>
JOIN recipe_agg ra ON pc.recipe_name = ra.recipe_name
LEFT JOIN central_kitchen_processing_cost pc2 ON ra.material_code = pc2.product_code AND pc2.report_month = $1::date
WHERE bt.is_finished_product AND bt.level < 5
${productCode ? '' : 'AND bt.level < 1'}
)
SELECT * FROM bom_tree ORDER BY root_product_code, level, theoretical_amt DESC
SELECT * FROM bom_tree
ORDER BY root_product_code, level, theoretical_amt DESC
${productCode ? '' : 'LIMIT 200'}
`, productCode ? [month, productCode] : [month]),
@@ -1846,29 +2113,34 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
const client = await pool.connect()
try {
// 创建临时表存储聚合销量,避免重复全表扫描550万行
// 检查物化视图是否包含当前月份数据,如缺失则刷新
const mvCheck = await client.query(`
SELECT 1 FROM mv_dish_sales_monthly WHERE month = $1::date LIMIT 1
`, [month])
if (mvCheck.rows.length === 0) {
// 物化视图中没有该月数据,刷新物化视图
await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dish_sales_monthly`)
}
// 从物化视图创建临时表(索引扫描,毫秒级)
await client.query(`
CREATE TEMP TABLE IF NOT EXISTS tmp_sales_agg AS
SELECT d.store_code, d.store_name, sk.sku_code, d.dish_name,
round(sum(d.sales_quantity)::numeric,4) AS qty,
round(sum(d.gross_amount)::numeric,2) AS amt,
round(avg(d.unit_price)::numeric,2) AS avg_unit_price
FROM dish_sales_details d
JOIN analytics.dim_sku sk ON d.dish_name = sk.standard_name
WHERE d.ordered_at >= $1::date AND d.ordered_at < ($1::date + INTERVAL '1 month')
${storeCode ? 'AND d.store_code = $2' : ''}
GROUP BY d.store_code, d.store_name, sk.sku_code, d.dish_name
SELECT store_code, store_name, sku_code, dish_name, qty, amt, avg_unit_price
FROM mv_dish_sales_monthly
WHERE month = $1::date
${storeCode ? 'AND store_code = $2' : ''}
`, storeCode ? [month, storeCode] : [month])
await client.query(`CREATE INDEX IF NOT EXISTS idx_tmp_sales_sku ON tmp_sales_agg(sku_code)`)
await client.query(`CREATE INDEX IF NOT EXISTS idx_tmp_sales_store ON tmp_sales_agg(store_code)`)
// 总销量(从临时表获取匹配菜品的汇总,从原始表获取全部菜品数
// 总销量(全部从临时表获取,无需再扫描原表
const totalSales = await client.query(`
SELECT
(SELECT count(*) FROM (SELECT DISTINCT dish_name FROM dish_sales_details WHERE ordered_at >= $1::date AND ordered_at < ($1::date + INTERVAL '1 month')) t) AS total_dish_count,
(SELECT round(sum(gross_amount)::numeric,2) FROM dish_sales_details WHERE ordered_at >= $1::date AND ordered_at < ($1::date + INTERVAL '1 month')) AS total_sales_amt
`, [month])
count(DISTINCT dish_name) AS total_dish_count,
round(sum(amt)::numeric,2) AS total_sales_amt
FROM tmp_sales_agg
`)
const [summaryResult, skuSalesResult, materialDemandResult, storeDemandResult, ckProductionPlanResult, productionCoordResult] = await Promise.all([
// 汇总(使用临时表)
@@ -1916,7 +2188,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
LIMIT 50
`),
// 原料需求(使用临时表 × BOM展开)
// 原料需求(使用临时表 × BOM展开,价格通过名称匹配配送记录
client.query(`
WITH demand AS (
SELECT s.store_code, b.material_code,
@@ -1928,11 +2200,12 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
LEFT JOIN analytics.dim_material m ON b.material_code = m.material_code
LEFT JOIN (
SELECT item_code AS material_code, round(avg(unit_price_excl_tax)::numeric,4) AS unit_price
FROM central_kitchen_recipe_consumption
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY item_code
) ic ON b.material_code = ic.material_code
SELECT d.item_name, round(sum(d.outbound_total_amount)::numeric / NULLIF(sum(d.total_quantity), 0), 4) AS unit_price
FROM distribution_detail_records d
WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month')
AND NOT d.is_return AND d.item_name IS NOT NULL
GROUP BY d.item_name
) ic ON m.material_name = ic.item_name
GROUP BY s.store_code, b.material_code, m.material_name, b.unit
)
SELECT
@@ -1981,7 +2254,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
ORDER BY sd.total_amt DESC
`, [month]),
// 中央厨房生产计划(使用临时表)
// 中央厨房生产计划(使用临时表,通过名称匹配实际入库数据
client.query(`
WITH ck_demand AS (
SELECT b.material_code AS ck_product_code,
@@ -1992,10 +2265,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
FROM tmp_sales_agg s
JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
JOIN analytics.dim_material m ON b.material_code = m.material_code
WHERE b.material_code IN (
SELECT product_code FROM central_kitchen_processing_cost WHERE report_month = $1::date
)
OR m.material_name IN (
WHERE m.material_name IN (
SELECT product_name FROM central_kitchen_processing_cost WHERE report_month = $1::date
)
GROUP BY b.material_code, m.material_name, b.unit
@@ -2009,23 +2279,22 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
GROUP BY product_code, product_name
),
ck_combined AS (
SELECT COALESCE(d.ck_product_code, a.product_code) AS product_code,
COALESCE(d.ck_product_name, a.product_name) AS product_name,
SELECT d.ck_product_code AS product_code,
d.ck_product_name AS product_name,
COALESCE(d.unit, '') AS unit,
COALESCE(d.demand_qty, 0) AS demand_qty,
COALESCE(d.store_count, 0) AS store_count,
d.demand_qty,
d.store_count,
COALESCE(a.actual_inbound_qty, 0) AS actual_inbound_qty,
COALESCE(a.actual_cost, 0) AS actual_cost
FROM ck_demand d
FULL OUTER JOIN ck_actual a ON d.ck_product_code = a.product_code
LEFT JOIN ck_actual a ON d.ck_product_name = a.product_name
UNION ALL
SELECT a.product_code, a.product_name, '',
0, 0, a.actual_inbound_qty, a.actual_cost
FROM ck_actual a
WHERE a.product_code NOT IN (SELECT ck_product_code FROM ck_demand WHERE ck_product_code IS NOT NULL)
AND a.product_name NOT IN (SELECT ck_product_name FROM ck_demand WHERE ck_product_name IS NOT NULL)
WHERE a.product_name NOT IN (SELECT ck_product_name FROM ck_demand)
)
SELECT product_code, product_name, unit,
demand_qty, store_count, actual_inbound_qty, actual_cost,
@@ -2055,7 +2324,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
FROM distribution_detail_records d
WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month')
AND NOT d.is_return AND d.item_code IS NOT NULL
AND d.distribution_center_code = '3'
AND d.distribution_center_code = '2'
GROUP BY d.item_code, d.item_name
),
store_consumption AS (
+2 -2
View File
@@ -113,8 +113,8 @@ router.get('/alerts', async (req: AuthRequest, res) => {
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
sum(received_total) AS received
FROM analytics.bill_fact
WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.bill_fact)
FROM analytics.fact_bill
WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.fact_bill)
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
+19 -6
View File
@@ -152,7 +152,7 @@ router.get('/dow-traffic', async (req: AuthRequest, res) => {
const storeName = req.query.store as string
const month = parseMonth(req)
const params: any[] = [month]
let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= $1 AND c175 < $1::date + interval '1 month'`
let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175::timestamp >= $1::date AND c175::timestamp < $1::date + interval '1 month'`
if (storeName) {
params.push(storeName)
where += ` AND c003 = $${params.length}`
@@ -455,7 +455,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
count(*) AS bills
FROM bill_records
WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != ''
AND c175 >= $4 AND c175 < $4::date + interval '1 month'
AND c175::timestamp >= $4::date AND c175::timestamp < $4::date + interval '1 month'
GROUP BY hour, day_type, bill_date
),
hourly_stats AS (
@@ -721,12 +721,18 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
params.push(storeName)
where += ` AND org_level5 = $${params.length}`
}
params.push(month)
const monthParam = `$${params.length}`
// 将 2026-04 转为 2026年4月 格式匹配 salary_period
const [yr, mo] = month.split('-')
const periodLabel = `${parseInt(yr)}${parseInt(mo)}`
params.push(periodLabel)
where += ` AND salary_period = $${params.length}`
const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params)
const total = countResult.rows[0].count
params.push(month)
const monthParam = `$${params.length}`
params.push(pageSize, offset)
const result = await query(`
SELECT employee_code,
@@ -767,6 +773,9 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
// 岗位薪资对比
router.get('/position-salary-compare', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [yr, mo] = month.split('-')
const periodLabel = `${parseInt(yr)}${parseInt(mo)}`
const result = await query(`
SELECT position,
count(*) AS emp_count,
@@ -781,10 +790,11 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店'
AND position IS NOT NULL AND position != ''
AND salary_period = $1
GROUP BY position
HAVING count(*) >= 5
ORDER BY avg_gross DESC
`)
`, [periodLabel])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -795,6 +805,8 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
router.get('/turnover-stats', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [yr, mo] = month.split('-')
const periodLabel = `${parseInt(yr)}${parseInt(mo)}`
const result = await query(`
SELECT org_level5 AS store_name,
count(*) AS total_emp,
@@ -804,9 +816,10 @@ router.get('/turnover-stats', async (req: AuthRequest, res) => {
round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
AND salary_period = $2
GROUP BY org_level5
ORDER BY turnover_rate DESC NULLS LAST
`, [month])
`, [month, periodLabel])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
+71 -7
View File
@@ -123,8 +123,15 @@ router.get('/monthly-review', async (req: AuthRequest, res) => {
router.get('/followup', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup ORDER BY priority, store_code`)
sendSuccess(res, result.rows)
const month = req.query.month as string | undefined
if (month) {
const monthDate = month + '-01'
const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup WHERE plan_month = $1::date ORDER BY priority, store_code`, [monthDate])
sendSuccess(res, result.rows)
} else {
const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup ORDER BY priority, store_code`)
sendSuccess(res, result.rows)
}
} catch (err: any) {
sendError(res, err.message)
}
@@ -132,7 +139,8 @@ router.get('/followup', async (req: AuthRequest, res) => {
router.get('/grade-change', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.store_grade_change ORDER BY change_month DESC, store_code`)
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.store_grade_change WHERE change_month = $1::date ORDER BY change_month DESC, store_code`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -278,7 +286,49 @@ router.post('/notifications/dispatch', async (req: AuthRequest, res) => {
router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const result = await query(`SELECT * FROM analytics.v_store_daily_card WHERE store_code = $1 ORDER BY module`, [code])
const month = req.query.month as string | undefined
let dateFilter: string
let params: any[] = [code]
if (month) {
const monthDate = month + '-01'
dateFilter = `closed_at::date = (SELECT max(closed_at)::date FROM analytics.fact_bill WHERE closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') AND closed_at IS NOT NULL)`
params.push(monthDate)
} else {
dateFilter = `closed_at::date = (SELECT max(closed_at)::date FROM analytics.fact_bill WHERE closed_at IS NOT NULL)`
}
const result = await query(`
WITH target_day AS (
SELECT max(closed_at)::date AS business_date
FROM analytics.fact_bill
WHERE ${dateFilter.replace('closed_at', 'fact_bill.closed_at')}
),
today_stats AS (
SELECT bf.store_code, bf.store_name,
round(sum(bf.received_total), 2) AS received,
count(*) AS bill_count,
round(sum(bf.received_total) / count(*), 2) AS avg_bill
FROM analytics.fact_bill bf, target_day td
WHERE bf.store_code = $1 AND bf.closed_at::date = td.business_date
GROUP BY bf.store_code, bf.store_name
),
week_ago_stats AS (
SELECT bf.store_code,
round(sum(bf.received_total), 2) AS received,
count(*) AS bill_count,
round(sum(bf.received_total) / count(*), 2) AS avg_bill
FROM analytics.fact_bill bf, target_day td
WHERE bf.store_code = $1 AND bf.closed_at::date = td.business_date - interval '7 days'
GROUP BY bf.store_code
)
SELECT t.store_code, t.store_name, '收入' AS module,
jsonb_build_array(
jsonb_build_object('metric', '实收', 'value', t.received, 'baseline', COALESCE(w.received, 0), 'is_anomaly', t.received < (COALESCE(w.received, 0) * 0.8)),
jsonb_build_object('metric', '账单数', 'value', t.bill_count, 'baseline', COALESCE(w.bill_count, 0), 'is_anomaly', t.bill_count::numeric < (COALESCE(w.bill_count, 0) * 0.8)),
jsonb_build_object('metric', '客单价', 'value', t.avg_bill, 'baseline', COALESCE(w.avg_bill, 0), 'is_anomaly', t.avg_bill < (COALESCE(w.avg_bill, 0) * 0.9))
) AS anomalies
FROM today_stats t
LEFT JOIN week_ago_stats w ON t.store_code = w.store_code
`, params)
const tasks = await query(`
SELECT t.* FROM analytics.store_task t
WHERE t.store_code = $1 AND t.status IN ('待启动', '进行中')
@@ -601,14 +651,28 @@ router.get('/monthly-review/completion', async (req: AuthRequest, res) => {
router.get('/monthly-review/activity-list', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT marketing_plan, bill_count, consumption, discounts, received,
SELECT marketing_plan, bill_count, consumption, discounts, received,
avg_bill_value, discount_rate_pct, theoretical_margin_pct,
ROUND(discounts * 100.0 / NULLIF(consumption, 0), 2) as discount_cost_rate,
ROUND(received - discounts, 2) as net_contribution
FROM analytics.v_marketing_plan_summary
FROM (
SELECT marketing_plan,
count(*) AS bill_count,
sum(consumption) AS consumption,
sum(discount_total) AS discounts,
sum(received_total) AS received,
round(avg(received_total), 2) AS avg_bill_value,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.fact_bill
WHERE marketing_plan IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY marketing_plan
) t
ORDER BY received DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})