feat(scheduling): history view, execute/cancel lifecycle, CSV export, 7d trend
- Add 调度记录 modal: lists notifications by status, supports 标记已执行 (with after-mileage + notes) and 取消 for open records - Add CSV export of filtered suggestions (UTF-8 BOM for Excel); top candidate per row picked by same-region > can-qualify preference - Compute customer 7-day average alongside 30-day baseline in a single query; show trend indicator (up/down/flat) next to 客户日均 in list and detail card Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,7 @@ export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
|
||||
department: v.department,
|
||||
manager: v.manager,
|
||||
customerAvgDaily: v.customerAvgDaily,
|
||||
customerAvgDaily7d: v.customerAvgDaily7d,
|
||||
predictedYearEnd: v.predictedYearEnd,
|
||||
daysLeft: v.daysLeft,
|
||||
};
|
||||
|
||||
@@ -114,12 +114,16 @@ app.get('/', async (c) => {
|
||||
// ---- 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) ----
|
||||
// ---- 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(daily_km) as avg_daily
|
||||
`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()
|
||||
@@ -128,25 +132,30 @@ app.get('/', async (c) => {
|
||||
allPlates,
|
||||
) as [any[], unknown];
|
||||
|
||||
// Build plate → avg_daily map
|
||||
const plateAvgMap = new Map<string, number>();
|
||||
const plateAvg30Map = new Map<string, number>();
|
||||
const plateAvg7Map = new Map<string, number>();
|
||||
for (const row of dailyRows) {
|
||||
plateAvgMap.set(row.plate, Number(row.avg_daily) || 0);
|
||||
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));
|
||||
}
|
||||
|
||||
// Aggregate per customer: average of all plates belonging to each customer
|
||||
const customerPlates = new Map<string, number[]>();
|
||||
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 (!customerPlates.has(customer)) customerPlates.set(customer, []);
|
||||
const avg = plateAvgMap.get(plate);
|
||||
if (avg !== undefined) customerPlates.get(customer)!.push(avg);
|
||||
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 customerPlates) {
|
||||
if (avgs.length > 0) {
|
||||
customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +217,7 @@ app.get('/', async (c) => {
|
||||
|
||||
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;
|
||||
@@ -233,6 +243,7 @@ app.get('/', async (c) => {
|
||||
department: info?.department || null,
|
||||
manager: info?.manager || null,
|
||||
customerAvgDaily,
|
||||
customerAvgDaily7d,
|
||||
predictedYearEnd,
|
||||
daysLeft,
|
||||
classification,
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface EnrichedVehicle {
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
customerAvgDaily: number;
|
||||
customerAvgDaily7d: number;
|
||||
predictedYearEnd: number;
|
||||
daysLeft: number;
|
||||
classification: VehicleClassification;
|
||||
|
||||
Reference in New Issue
Block a user