From a011729fc9e5d153d38f60c04a3c5c0bc58e394a Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sun, 2 Aug 2026 21:28:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E8=8C=83=E5=9B=B4=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 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阈值 --- client/src/App.tsx | 75 +++--- client/src/components/Layout.tsx | 14 +- client/src/components/RoleRoute.tsx | 21 ++ client/src/pages/LoginPage.tsx | 9 +- client/src/pages/StorePage.tsx | 8 +- client/src/pages/TenantUsersPage.tsx | 280 +++++++++++++++++++-- server/src/config/database.ts | 85 +++++-- server/src/middleware/data-scope.ts | 6 +- server/src/routes/admin.ts | 43 ++++ server/src/routes/analytics-enhanced.ts | 16 +- server/src/routes/data.ts | 6 +- server/src/routes/situational-awareness.ts | 16 +- server/src/routes/smart-scheduling.ts | 5 +- 13 files changed, 474 insertions(+), 110 deletions(-) create mode 100644 client/src/components/RoleRoute.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 2dff414..b03cc9b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -36,8 +36,9 @@ import { MenuEngineeringPage } from '@/pages/MenuEngineeringPage' import { RegionComparisonPage } from '@/pages/RegionComparisonPage' import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage' import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage' -import { TenantManagementPage } from '@/pages/TenantManagementPage' -import { TenantUsersPage } from '@/pages/TenantUsersPage' +import { TenantManagementPage } from './pages/TenantManagementPage' +import { TenantUsersPage } from './pages/TenantUsersPage' +import { RoleRoute } from './components/RoleRoute' const queryClient = new QueryClient({ defaultOptions: { @@ -80,42 +81,42 @@ export default function App() { ) : ( - : } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + : user?.role === 'store' ? : user?.role === 'regional' ? : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 1691d04..f280dfe 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -27,19 +27,19 @@ const menuGroups: MenuGroup[] = [ title: '经营总览', items: [ { path: '/boss', label: '老板驾驶舱', icon: Crown, roles: ['hq', 'dept'] }, - { path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] }, - { path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] }, + { path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept'] }, + { path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional', 'store'] }, { path: '/bank', label: '银行授信', icon: Landmark, roles: ['hq'] }, ], }, { title: '角色工作台', items: [ - { path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] }, - { path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept'] }, - { path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'regional', 'store'] }, + { path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional'] }, + { path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept', 'regional'] }, + { path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'store'] }, { 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: '/menu-engineering', label: '菜单工程', icon: Package, 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'] }, ], }, diff --git a/client/src/components/RoleRoute.tsx b/client/src/components/RoleRoute.tsx new file mode 100644 index 0000000..c9317d6 --- /dev/null +++ b/client/src/components/RoleRoute.tsx @@ -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 + } + + return <>{children} +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 3c91a24..bd0dcf6 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -20,7 +20,14 @@ export function LoginPage({ onLogin }: LoginPageProps) { localStorage.setItem('token', res.data.token) localStorage.setItem('user', JSON.stringify(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 { setError('登录失败,请重试') } diff --git a/client/src/pages/StorePage.tsx b/client/src/pages/StorePage.tsx index bdb4859..7b2f787 100644 --- a/client/src/pages/StorePage.tsx +++ b/client/src/pages/StorePage.tsx @@ -37,7 +37,13 @@ export function StorePage() { const queryClient = useQueryClient() const [executeText, setExecuteText] = useState('') const [activeTaskId, setActiveTaskId] = useState(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 [detailTab, setDetailTab] = useState('overview') diff --git a/client/src/pages/TenantUsersPage.tsx b/client/src/pages/TenantUsersPage.tsx index 0d6d8cd..4382a82 100644 --- a/client/src/pages/TenantUsersPage.tsx +++ b/client/src/pages/TenantUsersPage.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react' +import { useState, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '@/lib/api' 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 = { hq: '总部管理员', @@ -11,19 +11,48 @@ const ROLE_LABELS: Record = { dept: '商品部', } +interface StoreInfo { + store_code: string + store_name: string + region: string + business_type: string + business_area: string | null +} + export function TenantUsersPage() { const qc = useQueryClient() const [showAdd, setShowAdd] = useState(false) + const [showEdit, setShowEdit] = useState(false) const [showPassword, setShowPassword] = useState(false) const [editUserId, setEditUserId] = useState(null) 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 [storeFilter, setStoreFilter] = useState('') const { data, isLoading } = useQuery({ queryKey: ['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 = {} + 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({ mutationFn: (data: any) => api.post('/admin/tenant/users', data), 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 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 (
@@ -75,30 +118,32 @@ export function TenantUsersPage() { - - - + + + - - - + + + {users.map((u: any) => ( - - - + + -
用户名姓名角色用户名姓名角色 关联信息状态创建时间操作状态创建时间操作
{u.username}{u.name} + {u.username}{u.name} {ROLE_LABELS[u.role] || u.role} - {u.store_code && 门店:{u.store_code}} - {u.region && 门店:{u.region}} - {u.dept && 部门:{u.dept}} - {!u.store_code && !u.region && !u.dept && '-'} + +
+ {u.store_code &&
门店: {storeNames(u.store_code)}
} + {u.region &&
管辖门店: {storeNames(u.region)}
} + {u.dept &&
部门: {u.dept}
} + {!u.store_code && !u.region && !u.dept && '-'} +
@@ -110,6 +155,18 @@ export function TenantUsersPage() {
+
{addForm.role === 'store' && (
- + setAddForm({ ...addForm, store_code: e.target.value })} - placeholder="如:pjy" + value={storeFilter} + onChange={(e) => setStoreFilter(e.target.value)} + placeholder="搜索门店名称或区域..." className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" /> +
+ {filteredStores.map(s => { + const selected = addForm.store_code.split(',').map(c => c.trim()).includes(s.store_code) + return ( + + ) + })} +
+ {addForm.store_code && ( +

已选 {addForm.store_code.split(',').filter(Boolean).length} 家门店

+ )}
)} {addForm.role === 'regional' && (
- + setAddForm({ ...addForm, region: e.target.value })} - placeholder="如:0054,0032,0001" + value={storeFilter} + onChange={(e) => setStoreFilter(e.target.value)} + placeholder="搜索门店名称或区域..." className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" /> +
+ {filteredStores.map(s => { + const selected = addForm.region.split(',').map(c => c.trim()).includes(s.store_code) + return ( + + ) + })} +
+ {addForm.region && ( +

已选 {addForm.region.split(',').filter(Boolean).length} 家门店

+ )}
)} {addForm.role === 'dept' && ( @@ -237,6 +346,127 @@ export function TenantUsersPage() { )} + {/* 编辑用户弹窗 */} + {showEdit && editUserId !== null && ( +
+
+
+

编辑关联门店

+ +
+
+ {(() => { + const u = users.find((x: any) => x.id === editUserId) + if (!u) return null + return ( + <> +
+ 用户:{u.username}({ROLE_LABELS[u.role] || u.role}) +
+ {u.role === 'store' && ( +
+ + setStoreFilter(e.target.value)} + placeholder="搜索门店名称或区域..." + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ {filteredStores.map(s => { + const selected = editForm.store_code.split(',').map(c => c.trim()).includes(s.store_code) + return ( + + ) + })} +
+ {editForm.store_code && ( +

已选 {editForm.store_code.split(',').filter(Boolean).length} 家门店

+ )} +
+ )} + {u.role === 'regional' && ( +
+ + setStoreFilter(e.target.value)} + placeholder="搜索门店名称或区域..." + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ {filteredStores.map(s => { + const selected = editForm.region.split(',').map(c => c.trim()).includes(s.store_code) + return ( + + ) + })} +
+ {editForm.region && ( +

已选 {editForm.region.split(',').filter(Boolean).length} 家门店

+ )} +
+ )} + {u.role === 'dept' && ( +
+ + setEditForm({ ...editForm, dept: e.target.value })} + placeholder="如:商品部" + className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" + /> +
+ )} + + ) + })()} + {updateUser.isError && ( +

{(updateUser.error as any)?.data?.error || '修改失败'}

+ )} + +
+
+
+ )} + {/* 修改密码弹窗 */} {showPassword && editUserId !== null && (
diff --git a/server/src/config/database.ts b/server/src/config/database.ts index 961866a..5d250c1 100644 --- a/server/src/config/database.ts +++ b/server/src/config/database.ts @@ -34,35 +34,68 @@ export async function query(text: string, params?: any[], opts?: { skip if (!opts?.skipScope && ctx?.scope && (ctx.scope.role === 'store' || ctx.scope.role === 'regional')) { const sqlLower = sql.toLowerCase() - const hasStoreRef = /store_code|store_name|v_store_|mv_store_|bill_fact|fact_bill|store_task|dim_store/.test(sqlLower) - if (!hasStoreRef) { - const res = await usePool.query(sql, sqlParams) - const duration = Date.now() - start - if (duration > 500) { - console.warn(`Slow query (${duration}ms):`, sql.substring(0, 100)) - } - return res - } + const scopeCodes = ctx.scope.role === 'store' + ? ctx.scope.storeCode!.split(',').map(s => s.trim()).filter(Boolean) + : ctx.scope.region!.split(',').map(s => s.trim()).filter(Boolean) - const scopeClause = ctx.scope.role === 'store' && ctx.scope.storeCode - ? ` store_code = $${sqlParams.length + 1}` - : ctx.scope.role === 'regional' && ctx.scope.region - ? ` store_code = ANY($${sqlParams.length + 1})` - : null + if (scopeCodes.length > 0) { + // Handle raw import tables with non-standard store columns + const hasBillRecords = /\bbill_records\b/.test(sqlLower) + const hasSalaryRecords = /\bsalary_detail_records\b/.test(sqlLower) + const hasAttendanceRecords = /\battendance_records\b/.test(sqlLower) + const rawTableCount = [hasBillRecords, hasSalaryRecords, hasAttendanceRecords].filter(Boolean).length - if (scopeClause) { - const scopeValue = ctx.scope.role === 'store' - ? ctx.scope.storeCode - : ctx.scope.region!.split(',').map(s => s.trim()).filter(Boolean) - sqlParams = [...sqlParams, scopeValue] - if (sql.includes('WHERE')) { - sql = sql.replace('WHERE', `WHERE${scopeClause} AND`) - } else if (sql.includes('GROUP BY')) { - sql = sql.replace('GROUP BY', `WHERE${scopeClause} GROUP BY`) - } else if (sql.includes('ORDER BY')) { - sql = sql.replace('ORDER BY', `WHERE${scopeClause} ORDER BY`) + let scopeClause: string | null = null + if (rawTableCount === 1 && hasBillRecords) { + scopeClause = ` c003 IN (SELECT store_name FROM analytics.dim_store WHERE store_code = ANY($${sqlParams.length + 1}))` + } else if (rawTableCount === 1 && hasSalaryRecords) { + 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}))` + } else if (rawTableCount === 1 && hasAttendanceRecords) { + 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 (rawTableCount >= 2 && hasSalaryRecords) { + // Multiple raw tables with salary_detail_records as main table (e.g. attendance-alert) + 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}))` } 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 " 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
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') + } } } } diff --git a/server/src/middleware/data-scope.ts b/server/src/middleware/data-scope.ts index e4f0b76..57a5cc9 100644 --- a/server/src/middleware/data-scope.ts +++ b/server/src/middleware/data-scope.ts @@ -35,10 +35,12 @@ export function scopeStoreFilter(scope: DataScope | null, existingParams: any[]) if (!scope) return { clause: '', params: existingParams } 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 return { - clause: ` AND store_code = $${idx}`, - params: [...existingParams, scope.storeCode], + clause: ` AND store_code = ANY($${idx})`, + params: [...existingParams, codes], } } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 3101c8b..5edebc2 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -4,6 +4,7 @@ import bcrypt from 'bcrypt' import { sendSuccess, sendError } from '../middleware/error.js' import { generateToken, AuthRequest } from '../middleware/auth.js' import adminPool from '../config/admin-pool.js' +import { query } from '../config/database.js' import type { AuthUser } from '../types/index.js' 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 diff --git a/server/src/routes/analytics-enhanced.ts b/server/src/routes/analytics-enhanced.ts index 7d7c2c8..b6e86cd 100644 --- a/server/src/routes/analytics-enhanced.ts +++ b/server/src/routes/analytics-enhanced.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { query } from '../config/database.js' import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js' import type { AuthRequest } from '../middleware/auth.js' +import { getDataScope } from '../middleware/data-scope.js' const router = Router() @@ -763,6 +764,15 @@ router.get('/kpi', async (req: AuthRequest, res) => { sendSuccess(res, result.rows[0] || {}) } else if (level === 'region') { // 按区域汇总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(` WITH actual AS ( SELECT @@ -774,7 +784,7 @@ router.get('/kpi', async (req: AuthRequest, res) => { 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 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 ), target AS ( @@ -786,7 +796,7 @@ router.get('/kpi', async (req: AuthRequest, res) => { FROM analytics.dim_store_target t JOIN analytics.dim_store ds ON t.store_code = ds.store_code 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 ) SELECT @@ -807,7 +817,7 @@ router.get('/kpi', async (req: AuthRequest, res) => { FROM actual a LEFT JOIN target t ON a.region = t.region ORDER BY a.actual_revenue DESC - `, [month]) + `, scopeParams, { skipScope: true }) sendSuccess(res, result.rows) } else { // 总部汇总KPI diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index 50bbb7b..f047d3d 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -734,6 +734,8 @@ router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => { router.get('/region/summary', async (req: AuthRequest, res) => { try { const month = parseMonth(req) + const scope = getDataScope(req) + const { clause, params } = scopeStoreFilter(scope, [month]) const result = await query(` SELECT d.region, 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 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') + 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 ORDER BY total_received DESC - `, [month]) + `, params, { skipScope: true }) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts index bfcf23c..9ceba25 100644 --- a/server/src/routes/situational-awareness.ts +++ b/server/src/routes/situational-awareness.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { query } from '../config/database.js' import { sendSuccess, sendError, parseMonth } from '../middleware/error.js' import type { AuthRequest } from '../middleware/auth.js' +import { getDataScope } from '../middleware/data-scope.js' const router = Router() @@ -10,13 +11,20 @@ const router = Router() router.get('/health-score', async (req: AuthRequest, res) => { try { 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(` WITH base AS ( SELECT store_code, store_name, received, bill_count, avg_bill_value, avg_daily_received, theoretical_margin_pct, member_bill_share_pct, risk_level 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 ( SELECT store_code, @@ -93,7 +101,7 @@ router.get('/health-score', async (req: AuthRequest, res) => { END AS health_status FROM scored ORDER BY health_score DESC - `, [month]) + `, scopeParams, { skipScope: true }) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -129,7 +137,7 @@ router.get('/alerts', async (req: AuthRequest, res) => { WHERE d.received < m.avg_received * 0.7 ORDER BY d.business_date DESC LIMIT 10 - `) + `, [], { skipScope: true }) revenueAlerts.rows.forEach((r: any) => { alerts.push({ type: 'revenue', @@ -153,7 +161,7 @@ router.get('/alerts', async (req: AuthRequest, res) => { AND (cost_variance_amount / theoretical_cost) > 0.3 ORDER BY variance_pct DESC LIMIT 10 - `) + `, [], { skipScope: true }) costAlerts.rows.forEach((r: any) => { alerts.push({ type: 'cost', diff --git a/server/src/routes/smart-scheduling.ts b/server/src/routes/smart-scheduling.ts index 2f2b022..6840019 100644 --- a/server/src/routes/smart-scheduling.ts +++ b/server/src/routes/smart-scheduling.ts @@ -776,6 +776,7 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => { const month = parseMonth(req) const [yr, mo] = month.split('-') const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月` + const minCount = req.user?.role === 'store' ? 1 : 5 const result = await query(` SELECT position, 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 salary_period = $1 GROUP BY position - HAVING count(*) >= 5 + HAVING count(*) >= $2 ORDER BY avg_gross DESC - `, [periodLabel]) + `, [periodLabel, minCount]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message)