feat(scheduling): role-based access + align list count with qualifiedCount
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Gate 智能调度 module on BI-SCHEDULE-OPT role (or full-access roles) via shared canAccessScheduling helper, replacing hardcoded userId allowlist - Thread roles[] through JWT payload → middleware → frontend nav - Add router guard that 403s non-authorized users on /api/scheduling/* - Emit replace_qualified suggestion for every qualified vehicle so list count matches the 已完成考核目标 card; recalc qualifiedCount / hopelessCount post-permission-filter for card↔list consistency Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,7 @@ app.get('/exchange', async (c) => {
|
||||
depCode: userInfo.depCode,
|
||||
depName,
|
||||
permissionLevel,
|
||||
roles: roleNames,
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function authMiddleware(c: Context, next: Next) {
|
||||
depCode: payload.depCode,
|
||||
depName: payload.depName,
|
||||
permissionLevel: payload.permissionLevel,
|
||||
roles: payload.roles ?? [],
|
||||
};
|
||||
c.set('user', user);
|
||||
return next();
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AuthUser {
|
||||
depCode: string;
|
||||
depName: string;
|
||||
permissionLevel: PermissionLevel;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
@@ -16,12 +17,16 @@ export interface JwtPayload {
|
||||
depCode: string;
|
||||
depName: string;
|
||||
permissionLevel: PermissionLevel;
|
||||
roles: string[];
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/** 全量权限角色名 */
|
||||
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader'];
|
||||
|
||||
/** 部门级权限角色名 */
|
||||
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
||||
// Re-export role constants and helpers from the shared module so existing
|
||||
// server imports (`from './types.js'`) keep working.
|
||||
export {
|
||||
FULL_ACCESS_ROLES,
|
||||
DEPT_ACCESS_ROLES,
|
||||
SCHEDULING_ACCESS_ROLES,
|
||||
canAccessScheduling,
|
||||
} from '../../shared/auth/roles.js';
|
||||
|
||||
@@ -157,12 +157,10 @@ export function generateSuggestions(
|
||||
}
|
||||
|
||||
// --- replace_qualified (medium priority) ---
|
||||
// Swap out the qualified car, swap in a car that NEEDS mileage.
|
||||
// The high-mileage customer will drive it hard → helps it reach target.
|
||||
// Exclude candidates already at target (gap <= 0) — swapping those in is pointless.
|
||||
// Every qualified vehicle gets a suggestion row so the list count matches
|
||||
// `qualifiedCount`. Candidates may be empty when no inventory vehicle can
|
||||
// reach target at this customer — the row still surfaces for manual review.
|
||||
for (const vehicle of qualified) {
|
||||
if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue;
|
||||
|
||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||
.filter((inv) => {
|
||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||
@@ -206,9 +204,6 @@ export function generateSuggestions(
|
||||
})
|
||||
;
|
||||
|
||||
// Skip if no candidate can reach target — swap would be pointless
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
||||
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
|
||||
const reason: ReasonBlock = {
|
||||
@@ -231,8 +226,11 @@ export function generateSuggestions(
|
||||
});
|
||||
}
|
||||
|
||||
// Remove suggestions with no candidates
|
||||
const filteredSuggestions = suggestions.filter((s) => s.candidates.length > 0);
|
||||
// Drop rescue_hopeless with no candidates — no actionable rescue available.
|
||||
// Keep every replace_qualified so the list count matches the qualifiedCount card.
|
||||
const filteredSuggestions = suggestions.filter(
|
||||
(s) => s.type === 'replace_qualified' || s.candidates.length > 0,
|
||||
);
|
||||
|
||||
// Sort: high priority first
|
||||
filteredSuggestions.sort((a, b) => {
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { Hono } from 'hono';
|
||||
import suggestionsRouter from './suggestions.js';
|
||||
import notifyRouter from './notify.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessScheduling } from '../../auth/types.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Module-level access guard. When auth middleware is active, `user` is set and
|
||||
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
|
||||
// When auth is bypassed (dev), `user` is undefined and requests pass through.
|
||||
app.use('*', async (c, next) => {
|
||||
const user = (c as any).get('user') as AuthUser | undefined;
|
||||
if (user && !canAccessScheduling(user.roles)) {
|
||||
return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
app.route('/suggestions', suggestionsRouter);
|
||||
app.route('/notify', notifyRouter);
|
||||
|
||||
|
||||
@@ -344,8 +344,8 @@ app.get('/', async (c) => {
|
||||
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
||||
const recentInterventionCount = await fetchRecentInterventionCount();
|
||||
const filteredSummary: SchedulingSummary = {
|
||||
qualifiedCount: summary.qualifiedCount,
|
||||
hopelessCount: summary.hopelessCount,
|
||||
qualifiedCount: filteredQualified,
|
||||
hopelessCount: filteredHopeless,
|
||||
suggestionCount: masked.length,
|
||||
estimatedGain: masked.filter((s: any) =>
|
||||
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
||||
|
||||
Reference in New Issue
Block a user