风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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">企业经营数据 · 信用评估 · 2026年4月</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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">收入结构 · 渠道分布 · 时段分析 · 门店排名 · 2026年4月</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
@@ -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">异常账单 · 零实收 · 收银风险 · 2026年4月</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 长尾治理建议 */}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">2026年4月 · 客流规律 × 排班匹配 × 人效优化 · 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>
|
||||
)
|
||||
|
||||
@@ -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,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">最新经营概览(5月1日)</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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">星期规律 · 小时分布 · 渠道趋势 · 2026年4月</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>
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user