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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user