fix: 角色权限与数据范围修复
- 添加 RoleRoute 路由守卫组件,防止越权访问页面 - 菜单配置:区域经理加'区域对比',店长加'态势感知' - database.ts: scope注入时检测主表别名避免store_code歧义 - analytics-enhanced.ts: KPI region模式手动注入scope过滤 - situational-awareness.ts: health-score手动注入scope,alerts加skipScope - smart-scheduling.ts: position-salary-compare店长角色降低HAVING阈值
This commit is contained in:
+38
-37
@@ -36,8 +36,9 @@ import { MenuEngineeringPage } from '@/pages/MenuEngineeringPage'
|
|||||||
import { RegionComparisonPage } from '@/pages/RegionComparisonPage'
|
import { RegionComparisonPage } from '@/pages/RegionComparisonPage'
|
||||||
import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage'
|
import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage'
|
||||||
import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage'
|
import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage'
|
||||||
import { TenantManagementPage } from '@/pages/TenantManagementPage'
|
import { TenantManagementPage } from './pages/TenantManagementPage'
|
||||||
import { TenantUsersPage } from '@/pages/TenantUsersPage'
|
import { TenantUsersPage } from './pages/TenantUsersPage'
|
||||||
|
import { RoleRoute } from './components/RoleRoute'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -80,42 +81,42 @@ export default function App() {
|
|||||||
) : (
|
) : (
|
||||||
<Layout user={user} onLogout={handleLogout}>
|
<Layout user={user} onLogout={handleLogout}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={user?.role === 'platform_admin' ? <Navigate to="/tenant-management" /> : <DashboardPage />} />
|
<Route path="/" element={user?.role === 'platform_admin' ? <Navigate to="/tenant-management" /> : user?.role === 'store' ? <Navigate to="/store" /> : user?.role === 'regional' ? <Navigate to="/regional" /> : <DashboardPage />} />
|
||||||
<Route path="/boss" element={<BossPage />} />
|
<Route path="/boss" element={<RoleRoute roles={['hq','dept']}><BossPage /></RoleRoute>} />
|
||||||
<Route path="/revenue" element={<RevenuePage />} />
|
<Route path="/revenue" element={<RoleRoute roles={['hq','dept']}><RevenuePage /></RoleRoute>} />
|
||||||
<Route path="/bank" element={<BankPage />} />
|
<Route path="/bank" element={<RoleRoute roles={['hq']}><BankPage /></RoleRoute>} />
|
||||||
<Route path="/central-kitchen" element={<CentralKitchenPage />} />
|
<Route path="/central-kitchen" element={<RoleRoute roles={['hq','dept']}><CentralKitchenPage /></RoleRoute>} />
|
||||||
<Route path="/distribution-reconciliation" element={<DistributionReconciliationPage />} />
|
<Route path="/distribution-reconciliation" element={<RoleRoute roles={['hq','dept']}><DistributionReconciliationPage /></RoleRoute>} />
|
||||||
<Route path="/bom-penetration" element={<BomPenetrationPage />} />
|
<Route path="/bom-penetration" element={<RoleRoute roles={['hq','dept']}><BomPenetrationPage /></RoleRoute>} />
|
||||||
<Route path="/production-plan" element={<ProductionPlanPage />} />
|
<Route path="/production-plan" element={<RoleRoute roles={['hq','dept']}><ProductionPlanPage /></RoleRoute>} />
|
||||||
<Route path="/regional" element={<RegionalPage />} />
|
<Route path="/regional" element={<RoleRoute roles={['hq','dept','regional']}><RegionalPage /></RoleRoute>} />
|
||||||
<Route path="/store" element={<StorePage />} />
|
<Route path="/store" element={<RoleRoute roles={['hq','dept','store']}><StorePage /></RoleRoute>} />
|
||||||
<Route path="/tasks" element={<TasksPage />} />
|
<Route path="/tasks" element={<RoleRoute roles={['hq','regional','store','dept']}><TasksPage /></RoleRoute>} />
|
||||||
<Route path="/tasks/:id" element={<TaskDetailPage />} />
|
<Route path="/tasks/:id" element={<RoleRoute roles={['hq','regional','store','dept']}><TaskDetailPage /></RoleRoute>} />
|
||||||
<Route path="/stores/:code" element={<StoreDetailPage />} />
|
<Route path="/stores/:code" element={<RoleRoute roles={['hq','regional','store','dept']}><StoreDetailPage /></RoleRoute>} />
|
||||||
<Route path="/monthly-review" element={<MonthlyReviewPage />} />
|
<Route path="/monthly-review" element={<RoleRoute roles={['hq','dept','regional']}><MonthlyReviewPage /></RoleRoute>} />
|
||||||
<Route path="/sku" element={<SKUPage />} />
|
<Route path="/sku" element={<RoleRoute roles={['hq','dept']}><SKUPage /></RoleRoute>} />
|
||||||
<Route path="/cost" element={<CostPage />} />
|
<Route path="/cost" element={<RoleRoute roles={['hq','dept']}><CostPage /></RoleRoute>} />
|
||||||
<Route path="/cost-analysis" element={<CostAnalysisPage />} />
|
<Route path="/cost-analysis" element={<RoleRoute roles={['hq','dept']}><CostAnalysisPage /></RoleRoute>} />
|
||||||
<Route path="/store-expense" element={<StoreExpensePage />} />
|
<Route path="/store-expense" element={<RoleRoute roles={['hq','dept']}><StoreExpensePage /></RoleRoute>} />
|
||||||
<Route path="/platform" element={<PlatformPage />} />
|
<Route path="/platform" element={<RoleRoute roles={['hq','dept']}><PlatformPage /></RoleRoute>} />
|
||||||
<Route path="/member" element={<MemberPage />} />
|
<Route path="/member" element={<RoleRoute roles={['hq','dept']}><MemberPage /></RoleRoute>} />
|
||||||
<Route path="/risk" element={<RiskPage />} />
|
<Route path="/risk" element={<RoleRoute roles={['hq','dept']}><RiskPage /></RoleRoute>} />
|
||||||
<Route path="/time" element={<TimePage />} />
|
<Route path="/time" element={<RoleRoute roles={['hq','dept']}><TimePage /></RoleRoute>} />
|
||||||
<Route path="/data-quality" element={<DataQualityPage />} />
|
<Route path="/data-quality" element={<RoleRoute roles={['hq','dept']}><DataQualityPage /></RoleRoute>} />
|
||||||
<Route path="/indicators" element={<IndicatorsPage />} />
|
<Route path="/indicators" element={<RoleRoute roles={['hq','dept','regional','store']}><IndicatorsPage /></RoleRoute>} />
|
||||||
<Route path="/ontology" element={<OntologyPage />} />
|
<Route path="/ontology" element={<RoleRoute roles={['hq','dept']}><OntologyPage /></RoleRoute>} />
|
||||||
<Route path="/site-selection" element={<SiteSelectionPage />} />
|
<Route path="/site-selection" element={<RoleRoute roles={['hq','dept']}><SiteSelectionPage /></RoleRoute>} />
|
||||||
<Route path="/smart-scheduling" element={<SmartSchedulingPage />} />
|
<Route path="/smart-scheduling" element={<RoleRoute roles={['hq','dept','regional','store']}><SmartSchedulingPage /></RoleRoute>} />
|
||||||
<Route path="/situational-awareness" element={<SituationalAwarenessPage />} />
|
<Route path="/situational-awareness" element={<RoleRoute roles={['hq','dept','regional','store']}><SituationalAwarenessPage /></RoleRoute>} />
|
||||||
<Route path="/login" element={<Navigate to="/" />} />
|
<Route path="/login" element={<Navigate to="/" />} />
|
||||||
<Route path="/member-ltv" element={<MemberLTVPage />} />
|
<Route path="/member-ltv" element={<RoleRoute roles={['hq','dept']}><MemberLTVPage /></RoleRoute>} />
|
||||||
<Route path="/menu-engineering" element={<MenuEngineeringPage />} />
|
<Route path="/menu-engineering" element={<RoleRoute roles={['hq','dept']}><MenuEngineeringPage /></RoleRoute>} />
|
||||||
<Route path="/region-comparison" element={<RegionComparisonPage />} />
|
<Route path="/region-comparison" element={<RoleRoute roles={['hq','dept','regional']}><RegionComparisonPage /></RoleRoute>} />
|
||||||
<Route path="/employee-performance" element={<EmployeePerformancePage />} />
|
<Route path="/employee-performance" element={<RoleRoute roles={['hq','dept']}><EmployeePerformancePage /></RoleRoute>} />
|
||||||
<Route path="/inventory-turnover" element={<InventoryTurnoverPage />} />
|
<Route path="/inventory-turnover" element={<RoleRoute roles={['hq','dept']}><InventoryTurnoverPage /></RoleRoute>} />
|
||||||
<Route path="/tenant-management" element={<TenantManagementPage />} />
|
<Route path="/tenant-management" element={<RoleRoute roles={['platform_admin']}><TenantManagementPage /></RoleRoute>} />
|
||||||
<Route path="/tenant-users" element={<TenantUsersPage />} />
|
<Route path="/tenant-users" element={<RoleRoute roles={['hq']}><TenantUsersPage /></RoleRoute>} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -27,19 +27,19 @@ const menuGroups: MenuGroup[] = [
|
|||||||
title: '经营总览',
|
title: '经营总览',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/boss', label: '老板驾驶舱', icon: Crown, roles: ['hq', 'dept'] },
|
{ path: '/boss', label: '老板驾驶舱', icon: Crown, roles: ['hq', 'dept'] },
|
||||||
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] },
|
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept'] },
|
||||||
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] },
|
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||||
{ path: '/bank', label: '银行授信', icon: Landmark, roles: ['hq'] },
|
{ path: '/bank', label: '银行授信', icon: Landmark, roles: ['hq'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '角色工作台',
|
title: '角色工作台',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
|
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional'] },
|
||||||
{ path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept'] },
|
{ path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept', 'regional'] },
|
||||||
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'regional', 'store'] },
|
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'store'] },
|
||||||
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
|
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
|
||||||
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional', 'store'] },
|
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,7 +70,7 @@ const menuGroups: MenuGroup[] = [
|
|||||||
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
|
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
|
||||||
{ path: '/menu-engineering', label: '菜单工程', icon: Package, roles: ['hq', 'dept'] },
|
{ path: '/menu-engineering', label: '菜单工程', icon: Package, roles: ['hq', 'dept'] },
|
||||||
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
|
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
|
||||||
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
|
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional', 'store'] },
|
||||||
{ path: '/employee-performance', label: '员工绩效', icon: CalendarClock, roles: ['hq', 'dept'] },
|
{ path: '/employee-performance', label: '员工绩效', icon: CalendarClock, roles: ['hq', 'dept'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface RoleRouteProps {
|
||||||
|
roles: string[]
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleRoute({ roles, children, fallback }: RoleRouteProps) {
|
||||||
|
const userStr = localStorage.getItem('user')
|
||||||
|
const user = userStr ? JSON.parse(userStr) : null
|
||||||
|
const role = user?.role
|
||||||
|
|
||||||
|
if (!role || !roles.includes(role)) {
|
||||||
|
const redirect = fallback || (role === 'store' ? '/store' : role === 'regional' ? '/regional' : role === 'platform_admin' ? '/tenant-management' : '/')
|
||||||
|
return <Navigate to={redirect} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@@ -20,7 +20,14 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
|||||||
localStorage.setItem('token', res.data.token)
|
localStorage.setItem('token', res.data.token)
|
||||||
localStorage.setItem('user', JSON.stringify(res.data.user))
|
localStorage.setItem('user', JSON.stringify(res.data.user))
|
||||||
onLogin?.(res.data.user)
|
onLogin?.(res.data.user)
|
||||||
navigate(isPlatformAdmin ? '/tenant-management' : '/')
|
if (isPlatformAdmin) {
|
||||||
|
navigate('/tenant-management')
|
||||||
|
} else {
|
||||||
|
const role = res.data.user?.role
|
||||||
|
if (role === 'store') navigate('/store')
|
||||||
|
else if (role === 'regional') navigate('/regional')
|
||||||
|
else navigate('/')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('登录失败,请重试')
|
setError('登录失败,请重试')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ export function StorePage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [executeText, setExecuteText] = useState('')
|
const [executeText, setExecuteText] = useState('')
|
||||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
|
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
|
||||||
const [storeCode, setStoreCode] = useState('1111')
|
const userStoreCode = (() => {
|
||||||
|
try {
|
||||||
|
const u = JSON.parse(localStorage.getItem('user') || '{}')
|
||||||
|
return u.storeCode || ''
|
||||||
|
} catch { return '' }
|
||||||
|
})()
|
||||||
|
const [storeCode, setStoreCode] = useState(userStoreCode || '1111')
|
||||||
const [month, setMonth] = useState('2026-04')
|
const [month, setMonth] = useState('2026-04')
|
||||||
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
|
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '@/lib/api'
|
import api from '@/lib/api'
|
||||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||||
import { UserPlus, Trash2, X, KeyRound, Users } from 'lucide-react'
|
import { UserPlus, Trash2, X, KeyRound, Users, Edit2 } from 'lucide-react'
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
hq: '总部管理员',
|
hq: '总部管理员',
|
||||||
@@ -11,19 +11,48 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
dept: '商品部',
|
dept: '商品部',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StoreInfo {
|
||||||
|
store_code: string
|
||||||
|
store_name: string
|
||||||
|
region: string
|
||||||
|
business_type: string
|
||||||
|
business_area: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export function TenantUsersPage() {
|
export function TenantUsersPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [editUserId, setEditUserId] = useState<number | null>(null)
|
const [editUserId, setEditUserId] = useState<number | null>(null)
|
||||||
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'regional', name: '', store_code: '', region: '', dept: '' })
|
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'regional', name: '', store_code: '', region: '', dept: '' })
|
||||||
|
const [editForm, setEditForm] = useState({ store_code: '', region: '', dept: '' })
|
||||||
const [passwordForm, setPasswordForm] = useState('')
|
const [passwordForm, setPasswordForm] = useState('')
|
||||||
|
const [storeFilter, setStoreFilter] = useState('')
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['tenant-users'],
|
queryKey: ['tenant-users'],
|
||||||
queryFn: () => api.get('/admin/tenant/users'),
|
queryFn: () => api.get('/admin/tenant/users'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: storesData } = useQuery({
|
||||||
|
queryKey: ['tenant-stores'],
|
||||||
|
queryFn: () => api.get('/admin/tenant/stores'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const stores: StoreInfo[] = (storesData as any)?.data || []
|
||||||
|
|
||||||
|
const storeMap = useMemo(() => {
|
||||||
|
const m: Record<string, StoreInfo> = {}
|
||||||
|
for (const s of stores) m[s.store_code] = s
|
||||||
|
return m
|
||||||
|
}, [stores])
|
||||||
|
|
||||||
|
const storeNames = (codes: string) => {
|
||||||
|
if (!codes) return ''
|
||||||
|
return codes.split(',').map(c => c.trim()).filter(Boolean).map(c => storeMap[c]?.store_name || c).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
const addUser = useMutation({
|
const addUser = useMutation({
|
||||||
mutationFn: (data: any) => api.post('/admin/tenant/users', data),
|
mutationFn: (data: any) => api.post('/admin/tenant/users', data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -49,10 +78,24 @@ export function TenantUsersPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateUser = useMutation({
|
||||||
|
mutationFn: ({ userId, data }: { userId: number; data: any }) =>
|
||||||
|
api.put(`/admin/tenant/users/${userId}`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['tenant-users'] })
|
||||||
|
setShowEdit(false)
|
||||||
|
setEditUserId(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner text="加载用户列表..." />
|
if (isLoading) return <LoadingSpinner text="加载用户列表..." />
|
||||||
|
|
||||||
const users = (data as any)?.data || []
|
const users = (data as any)?.data || []
|
||||||
|
|
||||||
|
const filteredStores = storeFilter
|
||||||
|
? stores.filter(s => s.store_name.includes(storeFilter) || s.region.includes(storeFilter) || s.store_code.includes(storeFilter))
|
||||||
|
: stores
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -75,30 +118,32 @@ export function TenantUsersPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">用户名</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap">用户名</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">姓名</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap">姓名</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">角色</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap">角色</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">关联信息</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">关联信息</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">状态</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap">状态</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">创建时间</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap">创建时间</th>
|
||||||
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground">操作</th>
|
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground whitespace-nowrap">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u: any) => (
|
{users.map((u: any) => (
|
||||||
<tr key={u.id} className="border-t">
|
<tr key={u.id} className="border-t">
|
||||||
<td className="px-4 py-2 font-medium">{u.username}</td>
|
<td className="px-4 py-2 font-medium whitespace-nowrap">{u.username}</td>
|
||||||
<td className="px-4 py-2">{u.name}</td>
|
<td className="px-4 py-2 whitespace-nowrap">{u.name}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||||
{ROLE_LABELS[u.role] || u.role}
|
{ROLE_LABELS[u.role] || u.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs text-muted-foreground">
|
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs">
|
||||||
{u.store_code && <span className="mr-1">门店:{u.store_code}</span>}
|
<div className="space-y-0.5">
|
||||||
{u.region && <span className="mr-1">门店:{u.region}</span>}
|
{u.store_code && <div>门店: {storeNames(u.store_code)}</div>}
|
||||||
{u.dept && <span>部门:{u.dept}</span>}
|
{u.region && <div>管辖门店: {storeNames(u.region)}</div>}
|
||||||
{!u.store_code && !u.region && !u.dept && '-'}
|
{u.dept && <div>部门: {u.dept}</div>}
|
||||||
|
{!u.store_code && !u.region && !u.dept && '-'}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={u.is_active ? 'text-green-600' : 'text-gray-400'}>
|
<span className={u.is_active ? 'text-green-600' : 'text-gray-400'}>
|
||||||
@@ -110,6 +155,18 @@ export function TenantUsersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right">
|
<td className="px-4 py-2 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditUserId(u.id)
|
||||||
|
setEditForm({ store_code: u.store_code || '', region: u.region || '', dept: u.dept || '' })
|
||||||
|
setShowEdit(true)
|
||||||
|
setStoreFilter('')
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-50"
|
||||||
|
title="编辑关联门店"
|
||||||
|
>
|
||||||
|
<Edit2 size={12} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditUserId(u.id)
|
setEditUserId(u.id)
|
||||||
@@ -145,7 +202,7 @@ export function TenantUsersPage() {
|
|||||||
{/* 添加用户弹窗 */}
|
{/* 添加用户弹窗 */}
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="w-80 rounded-lg border bg-card p-5 shadow-lg">
|
<div className="w-96 rounded-lg border bg-card p-5 shadow-lg">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-bold">添加用户</h2>
|
<h2 className="text-sm font-bold">添加用户</h2>
|
||||||
<button onClick={() => setShowAdd(false)}>
|
<button onClick={() => setShowAdd(false)}>
|
||||||
@@ -191,24 +248,76 @@ export function TenantUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
{addForm.role === 'store' && (
|
{addForm.role === 'store' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground">门店编码</label>
|
<label className="text-xs text-muted-foreground">所属门店(多选)</label>
|
||||||
<input
|
<input
|
||||||
value={addForm.store_code}
|
value={storeFilter}
|
||||||
onChange={(e) => setAddForm({ ...addForm, store_code: e.target.value })}
|
onChange={(e) => setStoreFilter(e.target.value)}
|
||||||
placeholder="如:pjy"
|
placeholder="搜索门店名称或区域..."
|
||||||
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
|
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border p-2 space-y-1">
|
||||||
|
{filteredStores.map(s => {
|
||||||
|
const selected = addForm.store_code.split(',').map(c => c.trim()).includes(s.store_code)
|
||||||
|
return (
|
||||||
|
<label key={s.store_code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
const codes = addForm.store_code.split(',').map(c => c.trim()).filter(Boolean)
|
||||||
|
if (e.target.checked) {
|
||||||
|
setAddForm({ ...addForm, store_code: [...codes, s.store_code].join(',') })
|
||||||
|
} else {
|
||||||
|
setAddForm({ ...addForm, store_code: codes.filter(c => c !== s.store_code).join(',') })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{s.store_name}</span>
|
||||||
|
<span className="text-muted-foreground">{s.region}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{addForm.store_code && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">已选 {addForm.store_code.split(',').filter(Boolean).length} 家门店</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{addForm.role === 'regional' && (
|
{addForm.role === 'regional' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground">管辖门店编码</label>
|
<label className="text-xs text-muted-foreground">管辖门店(多选)</label>
|
||||||
<input
|
<input
|
||||||
value={addForm.region}
|
value={storeFilter}
|
||||||
onChange={(e) => setAddForm({ ...addForm, region: e.target.value })}
|
onChange={(e) => setStoreFilter(e.target.value)}
|
||||||
placeholder="如:0054,0032,0001"
|
placeholder="搜索门店名称或区域..."
|
||||||
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
|
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border p-2 space-y-1">
|
||||||
|
{filteredStores.map(s => {
|
||||||
|
const selected = addForm.region.split(',').map(c => c.trim()).includes(s.store_code)
|
||||||
|
return (
|
||||||
|
<label key={s.store_code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
const codes = addForm.region.split(',').map(c => c.trim()).filter(Boolean)
|
||||||
|
if (e.target.checked) {
|
||||||
|
setAddForm({ ...addForm, region: [...codes, s.store_code].join(',') })
|
||||||
|
} else {
|
||||||
|
setAddForm({ ...addForm, region: codes.filter(c => c !== s.store_code).join(',') })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{s.store_name}</span>
|
||||||
|
<span className="text-muted-foreground">{s.region}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{addForm.region && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">已选 {addForm.region.split(',').filter(Boolean).length} 家门店</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{addForm.role === 'dept' && (
|
{addForm.role === 'dept' && (
|
||||||
@@ -237,6 +346,127 @@ export function TenantUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 编辑用户弹窗 */}
|
||||||
|
{showEdit && editUserId !== null && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div className="w-96 rounded-lg border bg-card p-5 shadow-lg">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-bold">编辑关联门店</h2>
|
||||||
|
<button onClick={() => { setShowEdit(false); setEditUserId(null) }}>
|
||||||
|
<X size={16} className="text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(() => {
|
||||||
|
const u = users.find((x: any) => x.id === editUserId)
|
||||||
|
if (!u) return null
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
用户:<b>{u.username}</b>({ROLE_LABELS[u.role] || u.role})
|
||||||
|
</div>
|
||||||
|
{u.role === 'store' && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">所属门店(多选)</label>
|
||||||
|
<input
|
||||||
|
value={storeFilter}
|
||||||
|
onChange={(e) => setStoreFilter(e.target.value)}
|
||||||
|
placeholder="搜索门店名称或区域..."
|
||||||
|
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border p-2 space-y-1">
|
||||||
|
{filteredStores.map(s => {
|
||||||
|
const selected = editForm.store_code.split(',').map(c => c.trim()).includes(s.store_code)
|
||||||
|
return (
|
||||||
|
<label key={s.store_code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
const codes = editForm.store_code.split(',').map(c => c.trim()).filter(Boolean)
|
||||||
|
if (e.target.checked) {
|
||||||
|
setEditForm({ ...editForm, store_code: [...codes, s.store_code].join(',') })
|
||||||
|
} else {
|
||||||
|
setEditForm({ ...editForm, store_code: codes.filter(c => c !== s.store_code).join(',') })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{s.store_name}</span>
|
||||||
|
<span className="text-muted-foreground">{s.region}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{editForm.store_code && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">已选 {editForm.store_code.split(',').filter(Boolean).length} 家门店</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{u.role === 'regional' && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">管辖门店(多选)</label>
|
||||||
|
<input
|
||||||
|
value={storeFilter}
|
||||||
|
onChange={(e) => setStoreFilter(e.target.value)}
|
||||||
|
placeholder="搜索门店名称或区域..."
|
||||||
|
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border p-2 space-y-1">
|
||||||
|
{filteredStores.map(s => {
|
||||||
|
const selected = editForm.region.split(',').map(c => c.trim()).includes(s.store_code)
|
||||||
|
return (
|
||||||
|
<label key={s.store_code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
const codes = editForm.region.split(',').map(c => c.trim()).filter(Boolean)
|
||||||
|
if (e.target.checked) {
|
||||||
|
setEditForm({ ...editForm, region: [...codes, s.store_code].join(',') })
|
||||||
|
} else {
|
||||||
|
setEditForm({ ...editForm, region: codes.filter(c => c !== s.store_code).join(',') })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{s.store_name}</span>
|
||||||
|
<span className="text-muted-foreground">{s.region}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{editForm.region && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">已选 {editForm.region.split(',').filter(Boolean).length} 家门店</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{u.role === 'dept' && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">部门</label>
|
||||||
|
<input
|
||||||
|
value={editForm.dept}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, dept: e.target.value })}
|
||||||
|
placeholder="如:商品部"
|
||||||
|
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{updateUser.isError && (
|
||||||
|
<p className="text-xs text-red-600">{(updateUser.error as any)?.data?.error || '修改失败'}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => updateUser.mutate({ userId: editUserId, data: editForm })}
|
||||||
|
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 修改密码弹窗 */}
|
{/* 修改密码弹窗 */}
|
||||||
{showPassword && editUserId !== null && (
|
{showPassword && editUserId !== null && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
|||||||
@@ -34,35 +34,68 @@ export async function query<T = any>(text: string, params?: any[], opts?: { skip
|
|||||||
|
|
||||||
if (!opts?.skipScope && ctx?.scope && (ctx.scope.role === 'store' || ctx.scope.role === 'regional')) {
|
if (!opts?.skipScope && ctx?.scope && (ctx.scope.role === 'store' || ctx.scope.role === 'regional')) {
|
||||||
const sqlLower = sql.toLowerCase()
|
const sqlLower = sql.toLowerCase()
|
||||||
const hasStoreRef = /store_code|store_name|v_store_|mv_store_|bill_fact|fact_bill|store_task|dim_store/.test(sqlLower)
|
const scopeCodes = ctx.scope.role === 'store'
|
||||||
if (!hasStoreRef) {
|
? ctx.scope.storeCode!.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
const res = await usePool.query(sql, sqlParams)
|
: ctx.scope.region!.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
const duration = Date.now() - start
|
|
||||||
if (duration > 500) {
|
|
||||||
console.warn(`Slow query (${duration}ms):`, sql.substring(0, 100))
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeClause = ctx.scope.role === 'store' && ctx.scope.storeCode
|
if (scopeCodes.length > 0) {
|
||||||
? ` store_code = $${sqlParams.length + 1}`
|
// Handle raw import tables with non-standard store columns
|
||||||
: ctx.scope.role === 'regional' && ctx.scope.region
|
const hasBillRecords = /\bbill_records\b/.test(sqlLower)
|
||||||
? ` store_code = ANY($${sqlParams.length + 1})`
|
const hasSalaryRecords = /\bsalary_detail_records\b/.test(sqlLower)
|
||||||
: null
|
const hasAttendanceRecords = /\battendance_records\b/.test(sqlLower)
|
||||||
|
const rawTableCount = [hasBillRecords, hasSalaryRecords, hasAttendanceRecords].filter(Boolean).length
|
||||||
|
|
||||||
if (scopeClause) {
|
let scopeClause: string | null = null
|
||||||
const scopeValue = ctx.scope.role === 'store'
|
if (rawTableCount === 1 && hasBillRecords) {
|
||||||
? ctx.scope.storeCode
|
scopeClause = ` c003 IN (SELECT store_name FROM analytics.dim_store WHERE store_code = ANY($${sqlParams.length + 1}))`
|
||||||
: ctx.scope.region!.split(',').map(s => s.trim()).filter(Boolean)
|
} else if (rawTableCount === 1 && hasSalaryRecords) {
|
||||||
sqlParams = [...sqlParams, scopeValue]
|
scopeClause = ` org_level5 IN (SELECT COALESCE(m.salary_name, d.store_name) FROM analytics.dim_store d LEFT JOIN store_name_mapping m ON m.bill_name = d.store_name WHERE d.store_code = ANY($${sqlParams.length + 1}))`
|
||||||
if (sql.includes('WHERE')) {
|
} else if (rawTableCount === 1 && hasAttendanceRecords) {
|
||||||
sql = sql.replace('WHERE', `WHERE${scopeClause} AND`)
|
scopeClause = ` EXISTS (SELECT 1 FROM analytics.dim_store d WHERE d.store_code = ANY($${sqlParams.length + 1}) AND ${/\bar\b/.test(sqlLower) ? 'ar' : 'attendance_records'}.department LIKE '%' || d.store_name || '%')`
|
||||||
} else if (sql.includes('GROUP BY')) {
|
} else if (rawTableCount >= 2 && hasSalaryRecords) {
|
||||||
sql = sql.replace('GROUP BY', `WHERE${scopeClause} GROUP BY`)
|
// Multiple raw tables with salary_detail_records as main table (e.g. attendance-alert)
|
||||||
} else if (sql.includes('ORDER BY')) {
|
scopeClause = ` s.org_level5 IN (SELECT COALESCE(m.salary_name, d.store_name) FROM analytics.dim_store d LEFT JOIN store_name_mapping m ON m.bill_name = d.store_name WHERE d.store_code = ANY($${sqlParams.length + 1}))`
|
||||||
sql = sql.replace('ORDER BY', `WHERE${scopeClause} ORDER BY`)
|
|
||||||
} else {
|
} else {
|
||||||
sql = sql + ` WHERE${scopeClause}`
|
// For normal views/tables with store_code or store_name
|
||||||
|
const hasStoreCode = /\bstore_code\b/.test(sqlLower)
|
||||||
|
const hasStoreName = /\bstore_name\b/.test(sqlLower)
|
||||||
|
const hasStoreRef = hasStoreCode || hasStoreName || /v_store_|mv_store_|bill_fact|fact_bill|store_task|dim_store/.test(sqlLower)
|
||||||
|
if (hasStoreRef) {
|
||||||
|
// Detect main table alias to avoid ambiguous column reference
|
||||||
|
// Look for "FROM <table> <alias>" pattern where alias is a short identifier
|
||||||
|
const fromMatch = sql.match(/\bFROM\s+[\w.]+\s+(?:AS\s+)?(\w+)/i)
|
||||||
|
const mainAlias = fromMatch && !['where', 'group', 'order', 'left', 'right', 'inner', 'join', 'on', 'and', 'or', 'select', 'from', 'having', 'limit', 'union'].includes(fromMatch[1].toLowerCase()) ? fromMatch[1] : null
|
||||||
|
const colPrefix = mainAlias ? `${mainAlias}.` : ''
|
||||||
|
scopeClause = hasStoreCode
|
||||||
|
? ` ${colPrefix}store_code = ANY($${sqlParams.length + 1})`
|
||||||
|
: ` ${colPrefix}store_name IN (SELECT store_name FROM analytics.dim_store WHERE store_code = ANY($${sqlParams.length + 1}))`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeClause) {
|
||||||
|
sqlParams = [...sqlParams, scopeCodes]
|
||||||
|
// Inject scope filter into the main WHERE clause, skipping WHERE inside FILTER(WHERE ...) and subqueries
|
||||||
|
// Strategy: mask FILTER(WHERE), then replace the LAST WHERE (main query's WHERE comes after subquery WHEREs)
|
||||||
|
const maskToken = '__FWHM__'
|
||||||
|
const maskedSql = sql.replace(/FILTER\s*\(\s*WHERE\b/g, `FILTER (${maskToken}`)
|
||||||
|
const whereMatches = [...maskedSql.matchAll(/\bWHERE\b/g)]
|
||||||
|
if (whereMatches.length > 0) {
|
||||||
|
// Replace the last WHERE occurrence (main query WHERE is typically last)
|
||||||
|
const lastWhere = whereMatches[whereMatches.length - 1]
|
||||||
|
sql = maskedSql.substring(0, lastWhere.index!) + `WHERE${scopeClause} AND` + maskedSql.substring(lastWhere.index! + 5)
|
||||||
|
sql = sql.replace(new RegExp(maskToken, 'g'), 'WHERE')
|
||||||
|
} else {
|
||||||
|
// No WHERE clause: inject after the last FROM <table> in the main (non-CTE) query
|
||||||
|
const fromMatches = [...maskedSql.matchAll(/\bFROM\s+(\w+)/g)]
|
||||||
|
if (fromMatches.length > 0) {
|
||||||
|
const lastFrom = fromMatches[fromMatches.length - 1]
|
||||||
|
const insertPos = lastFrom.index! + lastFrom[0].length
|
||||||
|
sql = maskedSql.substring(0, insertPos) + ` WHERE${scopeClause}` + maskedSql.substring(insertPos)
|
||||||
|
} else {
|
||||||
|
sql = sql + ` WHERE${scopeClause}`
|
||||||
|
}
|
||||||
|
sql = sql.replace(new RegExp(maskToken, 'g'), 'WHERE')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ export function scopeStoreFilter(scope: DataScope | null, existingParams: any[])
|
|||||||
if (!scope) return { clause: '', params: existingParams }
|
if (!scope) return { clause: '', params: existingParams }
|
||||||
|
|
||||||
if (scope.role === 'store' && scope.storeCode) {
|
if (scope.role === 'store' && scope.storeCode) {
|
||||||
|
const codes = scope.storeCode.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (codes.length === 0) return { clause: '', params: existingParams }
|
||||||
const idx = existingParams.length + 1
|
const idx = existingParams.length + 1
|
||||||
return {
|
return {
|
||||||
clause: ` AND store_code = $${idx}`,
|
clause: ` AND store_code = ANY($${idx})`,
|
||||||
params: [...existingParams, scope.storeCode],
|
params: [...existingParams, codes],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import bcrypt from 'bcrypt'
|
|||||||
import { sendSuccess, sendError } from '../middleware/error.js'
|
import { sendSuccess, sendError } from '../middleware/error.js'
|
||||||
import { generateToken, AuthRequest } from '../middleware/auth.js'
|
import { generateToken, AuthRequest } from '../middleware/auth.js'
|
||||||
import adminPool from '../config/admin-pool.js'
|
import adminPool from '../config/admin-pool.js'
|
||||||
|
import { query } from '../config/database.js'
|
||||||
import type { AuthUser } from '../types/index.js'
|
import type { AuthUser } from '../types/index.js'
|
||||||
|
|
||||||
const { Pool } = pg
|
const { Pool } = pg
|
||||||
@@ -334,4 +335,46 @@ router.put('/tenant/users/:userId/password', async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 租户管理员更新内部用户信息(关联门店等)
|
||||||
|
router.put('/tenant/users/:userId', async (req: any, res) => {
|
||||||
|
if (!req.user || !req.user.tenantId) {
|
||||||
|
return sendError(res, 'Not a tenant user', 403)
|
||||||
|
}
|
||||||
|
if (req.user.role !== 'hq') {
|
||||||
|
return sendError(res, 'Only tenant admin can manage users', 403)
|
||||||
|
}
|
||||||
|
const { userId } = req.params
|
||||||
|
const { store_code, region, dept } = req.body
|
||||||
|
try {
|
||||||
|
const result = await adminPool.query(
|
||||||
|
'UPDATE tenant_users SET store_code = $1, region = $2, dept = $3 WHERE tenant_id = $4 AND id = $5 RETURNING id, username, role, name, store_code, region, dept, is_active, created_at',
|
||||||
|
[store_code || null, region || null, dept || null, req.user.tenantId, userId]
|
||||||
|
)
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return sendError(res, 'User not found', 404)
|
||||||
|
}
|
||||||
|
sendSuccess(res, result.rows[0])
|
||||||
|
} catch (err: any) {
|
||||||
|
sendError(res, err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 门店列表(含位置信息,供用户管理选择)
|
||||||
|
router.get('/tenant/stores', async (req: any, res) => {
|
||||||
|
if (!req.user || !req.user.tenantId) {
|
||||||
|
return sendError(res, 'Not a tenant user', 403)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await query(`
|
||||||
|
SELECT store_code, store_name, region, business_type, business_area
|
||||||
|
FROM analytics.dim_store
|
||||||
|
WHERE close_date IS NULL OR close_date > NOW()
|
||||||
|
ORDER BY store_code
|
||||||
|
`, [], { skipScope: true })
|
||||||
|
sendSuccess(res, result.rows)
|
||||||
|
} catch (err: any) {
|
||||||
|
sendError(res, err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
import { query } from '../config/database.js'
|
import { query } from '../config/database.js'
|
||||||
import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
|
import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
|
||||||
import type { AuthRequest } from '../middleware/auth.js'
|
import type { AuthRequest } from '../middleware/auth.js'
|
||||||
|
import { getDataScope } from '../middleware/data-scope.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -763,6 +764,15 @@ router.get('/kpi', async (req: AuthRequest, res) => {
|
|||||||
sendSuccess(res, result.rows[0] || {})
|
sendSuccess(res, result.rows[0] || {})
|
||||||
} else if (level === 'region') {
|
} else if (level === 'region') {
|
||||||
// 按区域汇总KPI
|
// 按区域汇总KPI
|
||||||
|
const scope = getDataScope(req)
|
||||||
|
const scopeCodes = scope?.role === 'store' || scope?.role === 'regional'
|
||||||
|
? (scope.storeCode || scope.region || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
const hasScope = scopeCodes.length > 0
|
||||||
|
const scopeParamIdx = hasScope ? 2 : 0
|
||||||
|
const scopeParams = hasScope ? [month, scopeCodes] : [month]
|
||||||
|
const scopeFilter = hasScope ? ` AND r.store_code = ANY($${scopeParamIdx})` : ''
|
||||||
|
const scopeFilterTarget = hasScope ? ` AND t.store_code = ANY($${scopeParamIdx})` : ''
|
||||||
const result = await query(`
|
const result = await query(`
|
||||||
WITH actual AS (
|
WITH actual AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -774,7 +784,7 @@ router.get('/kpi', async (req: AuthRequest, res) => {
|
|||||||
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
|
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
|
||||||
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
|
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
|
||||||
WHERE r.month_start = $1
|
WHERE r.month_start = $1
|
||||||
AND ds.region IS NOT NULL AND ds.region != '未知区域'
|
AND ds.region IS NOT NULL AND ds.region != '未知区域'${scopeFilter}
|
||||||
GROUP BY ds.region
|
GROUP BY ds.region
|
||||||
),
|
),
|
||||||
target AS (
|
target AS (
|
||||||
@@ -786,7 +796,7 @@ router.get('/kpi', async (req: AuthRequest, res) => {
|
|||||||
FROM analytics.dim_store_target t
|
FROM analytics.dim_store_target t
|
||||||
JOIN analytics.dim_store ds ON t.store_code = ds.store_code
|
JOIN analytics.dim_store ds ON t.store_code = ds.store_code
|
||||||
WHERE t.target_month = $1
|
WHERE t.target_month = $1
|
||||||
AND ds.region IS NOT NULL AND ds.region != '未知区域'
|
AND ds.region IS NOT NULL AND ds.region != '未知区域'${scopeFilterTarget}
|
||||||
GROUP BY ds.region
|
GROUP BY ds.region
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -807,7 +817,7 @@ router.get('/kpi', async (req: AuthRequest, res) => {
|
|||||||
FROM actual a
|
FROM actual a
|
||||||
LEFT JOIN target t ON a.region = t.region
|
LEFT JOIN target t ON a.region = t.region
|
||||||
ORDER BY a.actual_revenue DESC
|
ORDER BY a.actual_revenue DESC
|
||||||
`, [month])
|
`, scopeParams, { skipScope: true })
|
||||||
sendSuccess(res, result.rows)
|
sendSuccess(res, result.rows)
|
||||||
} else {
|
} else {
|
||||||
// 总部汇总KPI
|
// 总部汇总KPI
|
||||||
|
|||||||
@@ -734,6 +734,8 @@ router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
|
|||||||
router.get('/region/summary', async (req: AuthRequest, res) => {
|
router.get('/region/summary', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const month = parseMonth(req)
|
const month = parseMonth(req)
|
||||||
|
const scope = getDataScope(req)
|
||||||
|
const { clause, params } = scopeStoreFilter(scope, [month])
|
||||||
const result = await query(`
|
const result = await query(`
|
||||||
SELECT d.region,
|
SELECT d.region,
|
||||||
count(DISTINCT b.store_code) AS store_count,
|
count(DISTINCT b.store_code) AS store_count,
|
||||||
@@ -752,10 +754,10 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
|
|||||||
JOIN analytics.dim_store d ON d.store_code = b.store_code
|
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
|
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 <> ''
|
WHERE d.region IS NOT NULL AND d.region <> ''
|
||||||
AND b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month')
|
AND b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month')${clause.replace(' AND store_code', ' AND b.store_code')}
|
||||||
GROUP BY d.region
|
GROUP BY d.region
|
||||||
ORDER BY total_received DESC
|
ORDER BY total_received DESC
|
||||||
`, [month])
|
`, params, { skipScope: true })
|
||||||
sendSuccess(res, result.rows)
|
sendSuccess(res, result.rows)
|
||||||
} catch (err: any) { sendError(res, err.message) }
|
} catch (err: any) { sendError(res, err.message) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
import { query } from '../config/database.js'
|
import { query } from '../config/database.js'
|
||||||
import { sendSuccess, sendError, parseMonth } from '../middleware/error.js'
|
import { sendSuccess, sendError, parseMonth } from '../middleware/error.js'
|
||||||
import type { AuthRequest } from '../middleware/auth.js'
|
import type { AuthRequest } from '../middleware/auth.js'
|
||||||
|
import { getDataScope } from '../middleware/data-scope.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -10,13 +11,20 @@ const router = Router()
|
|||||||
router.get('/health-score', async (req: AuthRequest, res) => {
|
router.get('/health-score', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const month = parseMonth(req)
|
const month = parseMonth(req)
|
||||||
|
const scope = getDataScope(req)
|
||||||
|
const scopeCodes = scope?.role === 'store' || scope?.role === 'regional'
|
||||||
|
? (scope.storeCode || scope.region || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
const hasScope = scopeCodes.length > 0
|
||||||
|
const scopeParams = hasScope ? [month, scopeCodes] : [month]
|
||||||
|
const scopeFilter = hasScope ? ` AND store_code = ANY($2)` : ''
|
||||||
const result = await query(`
|
const result = await query(`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
SELECT store_code, store_name, received, bill_count,
|
SELECT store_code, store_name, received, bill_count,
|
||||||
avg_bill_value, avg_daily_received, theoretical_margin_pct,
|
avg_bill_value, avg_daily_received, theoretical_margin_pct,
|
||||||
member_bill_share_pct, risk_level
|
member_bill_share_pct, risk_level
|
||||||
FROM analytics.mv_store_risk_rating_monthly
|
FROM analytics.mv_store_risk_rating_monthly
|
||||||
WHERE month_start = $1 AND received IS NOT NULL
|
WHERE month_start = $1 AND received IS NOT NULL${scopeFilter}
|
||||||
),
|
),
|
||||||
cost AS (
|
cost AS (
|
||||||
SELECT store_code,
|
SELECT store_code,
|
||||||
@@ -93,7 +101,7 @@ router.get('/health-score', async (req: AuthRequest, res) => {
|
|||||||
END AS health_status
|
END AS health_status
|
||||||
FROM scored
|
FROM scored
|
||||||
ORDER BY health_score DESC
|
ORDER BY health_score DESC
|
||||||
`, [month])
|
`, scopeParams, { skipScope: true })
|
||||||
sendSuccess(res, result.rows)
|
sendSuccess(res, result.rows)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
sendError(res, err.message)
|
sendError(res, err.message)
|
||||||
@@ -129,7 +137,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
|
|||||||
WHERE d.received < m.avg_received * 0.7
|
WHERE d.received < m.avg_received * 0.7
|
||||||
ORDER BY d.business_date DESC
|
ORDER BY d.business_date DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`)
|
`, [], { skipScope: true })
|
||||||
revenueAlerts.rows.forEach((r: any) => {
|
revenueAlerts.rows.forEach((r: any) => {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
type: 'revenue',
|
type: 'revenue',
|
||||||
@@ -153,7 +161,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
|
|||||||
AND (cost_variance_amount / theoretical_cost) > 0.3
|
AND (cost_variance_amount / theoretical_cost) > 0.3
|
||||||
ORDER BY variance_pct DESC
|
ORDER BY variance_pct DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`)
|
`, [], { skipScope: true })
|
||||||
costAlerts.rows.forEach((r: any) => {
|
costAlerts.rows.forEach((r: any) => {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
type: 'cost',
|
type: 'cost',
|
||||||
|
|||||||
@@ -776,6 +776,7 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
|
|||||||
const month = parseMonth(req)
|
const month = parseMonth(req)
|
||||||
const [yr, mo] = month.split('-')
|
const [yr, mo] = month.split('-')
|
||||||
const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月`
|
const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月`
|
||||||
|
const minCount = req.user?.role === 'store' ? 1 : 5
|
||||||
const result = await query(`
|
const result = await query(`
|
||||||
SELECT position,
|
SELECT position,
|
||||||
count(*) AS emp_count,
|
count(*) AS emp_count,
|
||||||
@@ -792,9 +793,9 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
|
|||||||
AND position IS NOT NULL AND position != ''
|
AND position IS NOT NULL AND position != ''
|
||||||
AND salary_period = $1
|
AND salary_period = $1
|
||||||
GROUP BY position
|
GROUP BY position
|
||||||
HAVING count(*) >= 5
|
HAVING count(*) >= $2
|
||||||
ORDER BY avg_gross DESC
|
ORDER BY avg_gross DESC
|
||||||
`, [periodLabel])
|
`, [periodLabel, minCount])
|
||||||
sendSuccess(res, result.rows)
|
sendSuccess(res, result.rows)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
sendError(res, err.message)
|
sendError(res, err.message)
|
||||||
|
|||||||
Reference in New Issue
Block a user