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:
kkfluous
2026-04-16 23:47:31 +08:00
parent 3ef0d4edfa
commit 1d9f4cb43d
9 changed files with 459 additions and 18 deletions

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -39,6 +39,7 @@ export interface EnrichedVehicle {
department: string | null;
manager: string | null;
customerAvgDaily: number;
customerAvgDaily7d: number;
predictedYearEnd: number;
daysLeft: number;
classification: VehicleClassification;