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>
377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
import { Hono } from 'hono';
|
|
import pool from '../../db.js';
|
|
import mileagePool from '../../mileage-db.js';
|
|
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
|
import { mapRegion } from '../vehicles.js';
|
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
|
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
|
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
|
|
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
|
|
import type { AuthUser } from '../../auth/types.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: vehicle type classification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Infer vehicle type from target name when truck table has no match.
|
|
* e.g. "交投190辆4.5T冷链车" → "4.5T冷链", "羚牛100辆18T" → "18T"
|
|
*/
|
|
function inferTypeFromTargetName(targetName: string): string {
|
|
const t = targetName || '';
|
|
if (t.includes('冷链')) return '4.5T冷链';
|
|
if (t.includes('普货') || (t.includes('4.5') && !t.includes('冷链'))) return '4.5T普货';
|
|
if (t.includes('18T') || t.includes('18t')) return '18T';
|
|
if (t.includes('49') || t.includes('牵引')) return '49T';
|
|
if (t.includes('挂车')) return '挂车';
|
|
return '其他';
|
|
}
|
|
|
|
/**
|
|
* Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
|
|
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
|
|
*/
|
|
function classifyVehicleType(typeName: string, _modelRaw: string): string {
|
|
const t = (typeName || '').trim();
|
|
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
|
|
if (t.includes('4.5')) return '4.5T普货';
|
|
if (t.includes('18')) return '18T';
|
|
if (t.includes('49') || t.includes('牵引')) return '49T';
|
|
if (t.includes('挂车')) return '挂车';
|
|
return t || '其他';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const app = new Hono();
|
|
|
|
app.get('/', async (c) => {
|
|
try {
|
|
const targetIdParam = c.req.query('targetId');
|
|
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
|
|
|
|
// ---- Query 1: Assessment targets ----
|
|
const [targets] = await pool.execute(
|
|
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
|
|
) as [any[], unknown];
|
|
|
|
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
|
|
for (const t of targets) {
|
|
targetMap.set(t.id, {
|
|
targetName: t.target_name,
|
|
annualMileage: Number(t.annual_mileage_per_vehicle) || 0,
|
|
});
|
|
}
|
|
|
|
// ---- Query 2: Assessment vehicles ----
|
|
const [assessmentRows] = await pool.execute(`
|
|
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
|
|
current_mileage, current_year_mileage, current_year_mileage_task,
|
|
completion_rate, is_qualified, current_year_is_qualified,
|
|
daily_required_mileage, current_year_assessment_end_date
|
|
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
|
|
`) as [any[], unknown];
|
|
|
|
// ---- Query 3: Vehicle info (customer, dept, manager) ----
|
|
const vehicleInfoMap = await fetchVehicleInfoMap();
|
|
|
|
// ---- Query 4: Vehicle types from tab_truck ----
|
|
// Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck
|
|
// but are still active in the assessment. We need their type info.
|
|
const [truckTypeRows] = await pool.execute(`
|
|
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
|
|
FROM tab_truck truck
|
|
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
|
|
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
|
|
WHERE truck.is_operation = 1
|
|
`) as [any[], unknown];
|
|
|
|
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
|
|
for (const row of truckTypeRows) {
|
|
truckTypeMap.set(row.plate_number, {
|
|
typeName: row.type_name || '',
|
|
modelRaw: row.model_raw || '',
|
|
});
|
|
}
|
|
|
|
// ---- Query 5: Real-time location ----
|
|
const [locationRows] = await pool.execute(`
|
|
SELECT plate_number, province, city
|
|
FROM tab_truck_remote_sync_realtime_info
|
|
WHERE is_deleted = 0 AND plate_number IS NOT NULL
|
|
`) as [any[], unknown];
|
|
|
|
const locationMap = new Map<string, { province: string; city: string }>();
|
|
for (const row of locationRows) {
|
|
locationMap.set(row.plate_number, {
|
|
province: row.province || '',
|
|
city: row.city || '',
|
|
});
|
|
}
|
|
|
|
// ---- Collect all plates for Query 6 ----
|
|
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
|
|
|
|
// ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
|
|
const customerAvgDailyMap = new Map<string, number>();
|
|
const customerAvgDaily7dMap = new Map<string, number>();
|
|
if (allPlates.length > 0) {
|
|
const placeholders = allPlates.map(() => '?').join(',');
|
|
// Single query returning both windows per plate.
|
|
const [dailyRows] = await mileagePool.execute(
|
|
`SELECT plate,
|
|
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
|
|
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
|
|
FROM v_vehicle_daily_stats
|
|
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
|
AND stat_date < CURDATE()
|
|
AND plate IN (${placeholders})
|
|
GROUP BY plate`,
|
|
allPlates,
|
|
) as [any[], unknown];
|
|
|
|
const plateAvg30Map = new Map<string, number>();
|
|
const plateAvg7Map = new Map<string, number>();
|
|
for (const row of dailyRows) {
|
|
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
|
|
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
|
|
}
|
|
|
|
const customerPlates30 = new Map<string, number[]>();
|
|
const customerPlates7 = new Map<string, number[]>();
|
|
for (const plate of allPlates) {
|
|
const info = vehicleInfoMap.get(plate);
|
|
const customer = info?.customer || '未知客户';
|
|
if (!customerPlates30.has(customer)) customerPlates30.set(customer, []);
|
|
if (!customerPlates7.has(customer)) customerPlates7.set(customer, []);
|
|
const v30 = plateAvg30Map.get(plate);
|
|
const v7 = plateAvg7Map.get(plate);
|
|
if (v30 !== undefined) customerPlates30.get(customer)!.push(v30);
|
|
if (v7 !== undefined) customerPlates7.get(customer)!.push(v7);
|
|
}
|
|
for (const [customer, avgs] of customerPlates30) {
|
|
if (avgs.length > 0) customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
|
}
|
|
for (const [customer, avgs] of customerPlates7) {
|
|
if (avgs.length > 0) customerAvgDaily7dMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
|
}
|
|
}
|
|
|
|
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
|
|
const [inventoryTruckRows] = await pool.execute(`
|
|
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
|
|
FROM tab_truck truck
|
|
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
|
|
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
|
|
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
|
|
AND truck.truck_rent_status = 0
|
|
`) as [any[], unknown];
|
|
|
|
// ---- Build assessment vehicle lookup for inventory cross-reference ----
|
|
const assessmentByPlate = new Map<string, any>();
|
|
for (const row of assessmentRows) {
|
|
assessmentByPlate.set(row.plate_number, row);
|
|
}
|
|
|
|
// ---- Enrich assessment vehicles ----
|
|
const now = new Date();
|
|
const yearEnd = new Date(now.getFullYear(), 11, 31); // Dec 31
|
|
|
|
const enrichedVehicles: EnrichedVehicle[] = [];
|
|
for (const row of assessmentRows) {
|
|
const targetId = row.target_id as number;
|
|
if (filterTargetId !== null && targetId !== filterTargetId) continue;
|
|
|
|
const target = targetMap.get(targetId);
|
|
if (!target) continue;
|
|
|
|
const plate = row.plate_number as string;
|
|
const info = vehicleInfoMap.get(plate);
|
|
|
|
// Only include vehicles that are actively rented/operated (租赁 or 自营)
|
|
const rentStatus = info?.rent_status || '';
|
|
if (rentStatus !== '租赁' && rentStatus !== '自营') continue;
|
|
|
|
const loc = locationMap.get(plate);
|
|
const truckType = truckTypeMap.get(plate);
|
|
|
|
const province = loc?.province || '';
|
|
const city = loc?.city || '';
|
|
const region = mapRegion(province, city);
|
|
|
|
// Determine vehicle type: prefer truck table, fallback to target name
|
|
let vehicleType = '其他';
|
|
if (truckType) {
|
|
vehicleType = classifyVehicleType(truckType.typeName, truckType.modelRaw);
|
|
} else {
|
|
// Fallback: infer from target name (e.g. "交投190辆4.5T冷链车" → "4.5T冷链")
|
|
vehicleType = inferTypeFromTargetName(target.targetName);
|
|
}
|
|
|
|
const endDate = row.current_year_assessment_end_date
|
|
? new Date(row.current_year_assessment_end_date)
|
|
: yearEnd;
|
|
const daysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
|
|
|
|
const customer = info?.customer || null;
|
|
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
|
|
const customerAvgDaily7d = customerAvgDaily7dMap.get(customer || '未知客户') || 0;
|
|
const currentYearMileage = Number(row.current_year_mileage) || 0;
|
|
const yearTarget = Number(row.current_year_mileage_task) || 0;
|
|
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
|
|
|
|
const currentYearIsQualified = row.current_year_is_qualified === 1;
|
|
const classification = classifyVehicle(currentYearIsQualified, currentYearMileage, yearTarget, predictedYearEnd);
|
|
|
|
enrichedVehicles.push({
|
|
plateNumber: plate,
|
|
targetId,
|
|
targetName: target.targetName,
|
|
vehicleType,
|
|
totalMileage: Number(row.vehicle_total_mileage) || 0,
|
|
currentYearMileage,
|
|
completionRate: Number(row.completion_rate) || 0,
|
|
yearTarget,
|
|
isQualified: row.is_qualified === 1,
|
|
currentYearIsQualified,
|
|
dailyRequiredMileage: Number(row.daily_required_mileage) || 0,
|
|
region,
|
|
province,
|
|
customer,
|
|
department: info?.department || null,
|
|
manager: info?.manager || null,
|
|
customerAvgDaily,
|
|
customerAvgDaily7d,
|
|
predictedYearEnd,
|
|
daysLeft,
|
|
classification,
|
|
});
|
|
}
|
|
|
|
// ---- Build inventory vehicles ----
|
|
const inventoryVehicles: InventoryVehicle[] = [];
|
|
for (const row of inventoryTruckRows) {
|
|
const plate = row.plate_number as string;
|
|
const loc = locationMap.get(plate);
|
|
const province = loc?.province || '';
|
|
const city = loc?.city || '';
|
|
const region = mapRegion(province, city);
|
|
const vehicleType = classifyVehicleType(row.type_name || '', row.model_raw || '');
|
|
|
|
// Cross-reference with assessment data
|
|
const assessment = assessmentByPlate.get(plate);
|
|
// Compute this vehicle's own daysLeft from its assessment end date
|
|
let invDaysLeft = 0;
|
|
if (assessment?.current_year_assessment_end_date) {
|
|
const endDate = new Date(assessment.current_year_assessment_end_date);
|
|
invDaysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
|
|
} else {
|
|
invDaysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
|
}
|
|
inventoryVehicles.push({
|
|
plateNumber: plate,
|
|
vehicleType,
|
|
region,
|
|
province,
|
|
totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0,
|
|
daysLeft: invDaysLeft,
|
|
targetId: assessment ? (assessment.target_id as number) : null,
|
|
targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null,
|
|
yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null,
|
|
completionRate: assessment ? Number(assessment.completion_rate) || 0 : 0,
|
|
});
|
|
}
|
|
|
|
// ---- Run algorithm ----
|
|
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
|
|
|
|
// ---- Attach notification status to candidates ----
|
|
const notificationMap = await fetchActiveNotificationMap();
|
|
for (const s of suggestions) {
|
|
for (const c of s.candidates) {
|
|
const key = `${s.id}::${c.plateNumber}`;
|
|
const notif = notificationMap.get(key);
|
|
if (notif) {
|
|
c.notificationId = notif.id;
|
|
c.notificationStatus = notif.status;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Permission filtering & customer name masking ----
|
|
const user = (c as any).get('user') as AuthUser | undefined;
|
|
|
|
// Attach department/manager info so filterByPermission can work
|
|
const suggestionsWithPermFields = suggestions.map((s) => {
|
|
const info = vehicleInfoMap.get(s.currentVehicle.plateNumber);
|
|
return {
|
|
...s,
|
|
department: info?.department || null,
|
|
departmentName: info?.department || null,
|
|
managerId: info?.manager_id || null,
|
|
};
|
|
});
|
|
|
|
const filtered = user
|
|
? filterByPermission(suggestionsWithPermFields, user)
|
|
: suggestionsWithPermFields;
|
|
|
|
// Mask customer names in suggestions
|
|
const masked = maskCustomerNames(
|
|
filtered.map((s) => {
|
|
// Strip permission-filtering fields from response
|
|
const { department, departmentName, managerId, ...rest } = s;
|
|
return rest;
|
|
}),
|
|
);
|
|
|
|
// ---- Build target options list for filter UI ----
|
|
const targetVehicleCounts = new Map<number, number>();
|
|
for (const v of enrichedVehicles) {
|
|
targetVehicleCounts.set(v.targetId, (targetVehicleCounts.get(v.targetId) || 0) + 1);
|
|
}
|
|
|
|
const targetOptions = targets.map((t: any) => ({
|
|
id: t.id as number,
|
|
name: t.target_name as string,
|
|
vehicleCount: targetVehicleCounts.get(t.id) || 0,
|
|
}));
|
|
|
|
// Recalculate summary based on permission-filtered results
|
|
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
|
|
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
|
const recentInterventionCount = await fetchRecentInterventionCount();
|
|
const filteredSummary: SchedulingSummary = {
|
|
qualifiedCount: filteredQualified,
|
|
hopelessCount: filteredHopeless,
|
|
suggestionCount: masked.length,
|
|
estimatedGain: masked.filter((s: any) =>
|
|
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
|
).length,
|
|
recentInterventionCount,
|
|
};
|
|
|
|
const response: SchedulingResponse = {
|
|
summary: filteredSummary,
|
|
suggestions: masked,
|
|
targets: targetOptions,
|
|
};
|
|
|
|
return c.json(response);
|
|
} catch (e: unknown) {
|
|
console.error('scheduling suggestions error:', e);
|
|
return c.json(
|
|
{
|
|
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0, recentInterventionCount: 0 },
|
|
suggestions: [],
|
|
targets: [],
|
|
} satisfies SchedulingResponse,
|
|
500,
|
|
);
|
|
}
|
|
});
|
|
|
|
export default app;
|