diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..eec8677 Binary files /dev/null and b/.DS_Store differ diff --git a/Dockerfile b/Dockerfile index 2d605ec..34cd117 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist COPY src/server ./src/server +COPY src/shared ./src/shared COPY tsconfig.json ./ EXPOSE 3001 diff --git a/docker-compose.yml b/docker-compose.yml index ddc7e6e..5262bf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,10 +5,10 @@ services: image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 network_mode: host environment: - DB_HOST: "192.168.130.111" + DB_HOST: "47.101.148.99" DB_PORT: "3306" - DB_USER: "linsset_01" - DB_PASSWORD: "LN3456#&" + DB_USER: "root" + DB_PASSWORD: "LN#Passw0rd@2026" DB_NAME: "lingniu_prod" SERVER_PORT: "8111" EXTERNAL_API_BASE: "https://lnh2e.com" diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..894578a Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store new file mode 100644 index 0000000..5115dd2 Binary files /dev/null and b/docs/superpowers/.DS_Store differ diff --git a/docs/superpowers/plans/2026-03-27-three-modules.md b/docs/superpowers/plans/2026-03-27-three-modules.md new file mode 100644 index 0000000..764c269 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-three-modules.md @@ -0,0 +1,753 @@ +# Three Operations Modules Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add department, region, and customer operations statistics sections to the dashboard, ported from lnoneos prototype with real MySQL data. + +**Architecture:** All 3 new API endpoints aggregate from the existing `getVehicles()` cache (no new DB queries). A new macro-region mapping function converts province/city to 华东/华南/etc. The frontend adds 3 new collapsible sections below the existing asset summary table, each with desktop table + mobile card views. The existing vehicle list modal is extended with new filter params (manager, customer, isColdChain, isTrailer). + +**Tech Stack:** Hono (backend), React + Tailwind CSS + Motion (frontend), TypeScript throughout. + +**Reference:** lnoneos prototype at `/Users/kkfluous/Projects/ai-coding/lnoneos/src/App.tsx` + +--- + +### Task 1: Backend — Macro-region mapping + vehicle type classification helpers + +**Files:** +- Modify: `src/server/routes/vehicles.ts` (add functions after line ~111) + +- [ ] **Step 1: Add macro-region mapping function** + +Add after `mapInventoryRegion` (line 111) in `src/server/routes/vehicles.ts`: + +```typescript +// Macro-region mapping: province/city -> 华东/华南/华北/华中/西南/西北/其他 +function mapMacroRegion(province: string | null, city: string | null): string { + const prov = (province || '').trim(); + const c = (city || '').trim(); + const loc = prov + c; + // 华东: 上海/江苏/浙江/安徽/福建/江西/山东 + if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(loc)) return '华东'; + // 华南: 广东/广西/海南 + if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(loc)) return '华南'; + // 华北: 北京/天津/河北/山西/内蒙古 + if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(loc)) return '华北'; + // 华中: 河南/湖北/湖南 + if (/河南|湖北|湖南|郑州|武汉|长沙/.test(loc)) return '华中'; + // 西南: 重庆/四川/贵州/云南/西藏 + if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(loc)) return '西南'; + // 西北: 陕西/甘肃/青海/宁夏/新疆 + if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(loc)) return '西北'; + return '其他'; +} + +// Vehicle type classification for per-type counts +type VehicleTypeCounts = { t4_5: number; t4_5c: number; t18: number; t49: number; trailer: number; other: number; total: number }; + +function classifyVehicleType(v: Vehicle): keyof Omit { + if (v.type === '4.5T' && !v.model.includes('冷链')) return 't4_5'; + if (v.type === '4.5T' && v.model.includes('冷链')) return 't4_5c'; + if (v.type === '18T') return 't18'; + if (v.type === '49T') return 't49'; + if (v.type === '挂车' || v.model.includes('挂车')) return 'trailer'; + return 'other'; +} + +function countByType(vehicles: Vehicle[]): VehicleTypeCounts { + const counts: VehicleTypeCounts = { t4_5: 0, t4_5c: 0, t18: 0, t49: 0, trailer: 0, other: 0, total: 0 }; + for (const v of vehicles) { + counts[classifyVehicleType(v)]++; + counts.total++; + } + return counts; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/vehicles.ts +git commit -m "feat: add macro-region mapping and vehicle type classification helpers" +``` + +--- + +### Task 2: Backend — Three new API endpoints + +**Files:** +- Modify: `src/server/routes/vehicles.ts` (add endpoints before the `/list` endpoint) + +- [ ] **Step 1: Add `/dept-stats` endpoint** + +Add before the `VEHICLE_TYPE_FILTERS` const (which is before `/list`) in `src/server/routes/vehicles.ts`: + +```typescript +// GET /api/vehicles/dept-stats +app.get('/dept-stats', async (c) => { + const vehicles = await getVehicles(); + // Only count operating vehicles for department stats (those with a customerManager) + const withManager = vehicles.filter((v) => v.customerManager); + + const deptMap = new Map>(); + for (const v of withManager) { + const dept = v.departmentName || '未分配'; + const mgr = v.customerManager!; + if (!deptMap.has(dept)) deptMap.set(dept, new Map()); + const mgrMap = deptMap.get(dept)!; + if (!mgrMap.has(mgr)) mgrMap.set(mgr, []); + mgrMap.get(mgr)!.push(v); + } + + const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => { + const allDeptVehicles = Array.from(mgrMap.values()).flat(); + const managers = Array.from(mgrMap.entries()) + .map(([manager, mvs]) => ({ + manager, + department, + ...countByType(mvs), + })) + .sort((a, b) => b.total - a.total); + + return { + department, + totalAssets: allDeptVehicles.length, + operatingCount: allDeptVehicles.filter((v) => v.status === 'Operating').length, + idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length, + managers, + }; + }).sort((a, b) => b.totalAssets - a.totalAssets); + + return c.json(result); +}); +``` + +- [ ] **Step 2: Add `/region-stats` endpoint** + +```typescript +// GET /api/vehicles/region-stats +app.get('/region-stats', async (c) => { + const vehicles = await getVehicles(); + const operating = vehicles.filter((v) => v.status === 'Operating'); + + const regionMap = new Map(); + for (const v of operating) { + const region = mapMacroRegion(v.province, v.city); + if (!regionMap.has(region)) regionMap.set(region, []); + regionMap.get(region)!.push(v); + } + + const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他']; + const result = regionOrder + .filter((r) => regionMap.has(r)) + .map((region) => { + const rv = regionMap.get(region)!; + const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[]; + const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => { + const typeVehicles = rv.filter((v) => v.type === type); + return { + type, + total: typeVehicles.length, + operating: typeVehicles.filter((v) => v.status === 'Operating').length, + inventory: typeVehicles.filter((v) => v.status === 'Inventory').length, + customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[], + }; + }).filter((t) => t.total > 0); + + return { + region, + totalAssets: rv.length, + operatingCount: rv.filter((v) => v.status === 'Operating').length, + inventoryCount: rv.filter((v) => v.status === 'Inventory').length, + customers, + typeBreakdown, + }; + }); + + return c.json(result); +}); +``` + +- [ ] **Step 3: Add `/customer-stats` endpoint** + +```typescript +// GET /api/vehicles/customer-stats +app.get('/customer-stats', async (c) => { + const vehicles = await getVehicles(); + const operating = vehicles.filter((v) => v.status === 'Operating' && v.customerName); + + const custMap = new Map(); + for (const v of operating) { + const cust = v.customerName!; + if (!custMap.has(cust)) custMap.set(cust, []); + custMap.get(cust)!.push(v); + } + + const result = Array.from(custMap.entries()) + .map(([customer, cvs]) => { + const first = cvs[0]; + return { + customer, + manager: first.customerManager || '', + brand: first.brandLabel || '', + department: first.departmentName || '', + region: mapMacroRegion(first.province, first.city), + city: first.city || '', + ...countByType(cvs), + }; + }) + .sort((a, b) => b.total - a.total); + + return c.json(result); +}); +``` + +- [ ] **Step 4: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 5: Commit** + +```bash +git add src/server/routes/vehicles.ts +git commit -m "feat: add dept-stats, region-stats, customer-stats API endpoints" +``` + +--- + +### Task 3: Backend — Extend `/list` with new filter params + +**Files:** +- Modify: `src/server/routes/vehicles.ts` (the `/list` endpoint) + +- [ ] **Step 1: Add manager, customer, isColdChain, isTrailer filters** + +In the `/list` endpoint, after the existing `category` filter block, add: + +```typescript + const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer } = c.req.query(); +``` + +(Replace the existing destructure line.) + +Then after the `if (category)` block, add: + +```typescript + if (manager) { + filtered = filtered.filter((v) => v.customerManager === manager); + } + if (customer) { + filtered = filtered.filter((v) => v.customerName === customer); + } + if (isColdChain !== undefined) { + const wantCold = isColdChain === 'true'; + filtered = filtered.filter((v) => wantCold ? v.model.includes('冷链') : !v.model.includes('冷链')); + } + if (isTrailer !== undefined) { + const wantTrailer = isTrailer === 'true'; + filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车'))); + } +``` + +- [ ] **Step 2: Verify TypeScript compiles and build passes** + +Run: `npx tsc --noEmit && npx vite build` +Expected: no errors, successful build + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/vehicles.ts +git commit -m "feat: extend /list endpoint with manager, customer, coldchain, trailer filters" +``` + +--- + +### Task 4: Frontend — Types and API client + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/api.ts` + +- [ ] **Step 1: Add new interfaces to `src/types.ts`** + +Append at end of file: + +```typescript +export interface ManagerStats { + manager: string; + department: string; + t4_5: number; + t4_5c: number; + t18: number; + t49: number; + trailer: number; + other: number; + total: number; +} + +export interface DeptGroup { + department: string; + totalAssets: number; + operatingCount: number; + idleCount: number; + managers: ManagerStats[]; +} + +export interface RegionGroup { + region: string; + totalAssets: number; + operatingCount: number; + inventoryCount: number; + customers: string[]; + typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[]; +} + +export interface CustomerStats { + customer: string; + manager: string; + brand: string; + department: string; + region: string; + city: string; + t4_5: number; + t4_5c: number; + t18: number; + t49: number; + trailer: number; + other: number; + total: number; +} +``` + +- [ ] **Step 2: Add API functions to `src/api.ts`** + +Add imports at top: + +```typescript +import type { + SummaryData, + TypeSummary, + VehicleListItem, + DeptGroup, + RegionGroup, + CustomerStats, +} from './types'; +``` + +Add after `fetchVehicleList`: + +```typescript +export async function fetchDeptStats(): Promise { + return fetchJson(`${BASE}/dept-stats`); +} + +export async function fetchRegionStats(): Promise { + return fetchJson(`${BASE}/region-stats`); +} + +export async function fetchCustomerStats(): Promise { + return fetchJson(`${BASE}/customer-stats`); +} +``` + +Also update `fetchVehicleList` params type to include new filters: + +```typescript +export async function fetchVehicleList(params: { + batch?: string; + model?: string; + location?: string; + status?: string; + category?: string; + vehicleType?: string; + manager?: string; + customer?: string; + isColdChain?: string; + isTrailer?: string; +}): Promise { + const query = new URLSearchParams(); + if (params.batch) query.set('batch', params.batch); + if (params.model) query.set('model', params.model); + if (params.location) query.set('location', params.location); + if (params.status) query.set('status', params.status); + if (params.category) query.set('category', params.category); + if (params.vehicleType) query.set('vehicleType', params.vehicleType); + if (params.manager) query.set('manager', params.manager); + if (params.customer) query.set('customer', params.customer); + if (params.isColdChain) query.set('isColdChain', params.isColdChain); + if (params.isTrailer) query.set('isTrailer', params.isTrailer); + return fetchJson(`${BASE}/list?${query.toString()}`); +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add src/types.ts src/api.ts +git commit -m "feat: add frontend types and API client for dept/region/customer stats" +``` + +--- + +### Task 5: Frontend — Extend App.tsx state, data loading, imports, and showPlateNumbers + +**Files:** +- Modify: `src/App.tsx` + +- [ ] **Step 1: Update imports** + +Replace the existing import lines at top of `src/App.tsx`: + +```typescript +import { + Truck, + Warehouse, + Activity, + PlusCircle, + MinusCircle, + History, + ChevronDown, + ChevronRight, + Info, + Loader2, + Search, + Filter, + ArrowRightLeft, +} from 'lucide-react'; +``` + +Update type imports: + +```typescript +import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats } from './types'; +import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats } from './api'; +``` + +- [ ] **Step 2: Add new state variables** + +After the existing state declarations (after `const [modalLoading, setModalLoading] = useState(false);`), add: + +```typescript + // Dept/Region/Customer data + const [deptData, setDeptData] = useState([]); + const [regionData, setRegionData] = useState([]); + const [customerData, setCustomerData] = useState([]); + + // Dept section state + const [deptViewMode, setDeptViewMode] = useState<'department' | 'manager'>('department'); + const [expandedDepts, setExpandedDepts] = useState>(new Set()); + const [expandedManagerDetails, setExpandedManagerDetails] = useState>(new Set()); + const [selectedManager, setSelectedManager] = useState('All'); + + // Region section state + const [expandedRegions, setExpandedRegions] = useState>(new Set()); + const [regionFilters, setRegionFilters] = useState({ region: '', city: '', customer: '' }); + const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false); + + // Customer section state + const [expandedCustomers, setExpandedCustomers] = useState>(new Set()); + const [customerFilters, setCustomerFilters] = useState({ customer: '', brand: '', department: '', manager: '', region: '' }); + const [isCustomerFilterOpen, setIsCustomerFilterOpen] = useState(false); +``` + +- [ ] **Step 3: Update loadData to fetch all 3 new endpoints** + +Update the `loadData` callback: + +```typescript + const loadData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const [s, byType, dept, region, cust] = await Promise.all([ + fetchSummary(), + fetchByType(), + fetchDeptStats(), + fetchRegionStats(), + fetchCustomerStats(), + ]); + setSummary(s); + setProcessedData(byType); + setDeptData(dept); + setRegionData(region); + setCustomerData(cust); + setLastUpdate(new Date().toLocaleString('zh-CN')); + } catch (e) { + setError(e instanceof Error ? e.message : '数据加载失败'); + } finally { + setLoading(false); + } + }, []); +``` + +- [ ] **Step 4: Extend showPlateNumbers type** + +Update the showPlateNumbers state type: + +```typescript + const [showPlateNumbers, setShowPlateNumbers] = useState<{ + batch: string; + model: string; + location: string; + category?: 'Inventory' | 'Pending' | 'Delivered' | 'Returned' | 'Replaced' | 'Operating'; + vehicleType?: string; + manager?: string; + customer?: string; + isColdChain?: boolean; + isTrailer?: boolean; + } | null>(null); +``` + +- [ ] **Step 5: Update modal loading to pass new filter params** + +In the `useEffect` for modal loading, update the params block (the "Normal vehicle list" section): + +```typescript + // Normal vehicle list + setModalWeeklyDetail([]); + const params: Record = {}; + if (showPlateNumbers.vehicleType) params.vehicleType = showPlateNumbers.vehicleType; + if (showPlateNumbers.batch !== 'All') params.batch = showPlateNumbers.batch; + if (showPlateNumbers.model !== 'All') params.model = showPlateNumbers.model; + if (showPlateNumbers.location !== 'All') params.location = showPlateNumbers.location; + if (cat === 'Inventory') params.status = 'Inventory'; + if (cat === 'Operating') params.category = 'Operating'; + if (showPlateNumbers.manager) params.manager = showPlateNumbers.manager; + if (showPlateNumbers.customer) params.customer = showPlateNumbers.customer; + if (showPlateNumbers.isColdChain !== undefined) params.isColdChain = String(showPlateNumbers.isColdChain); + if (showPlateNumbers.isTrailer !== undefined) params.isTrailer = String(showPlateNumbers.isTrailer); +``` + +- [ ] **Step 6: Add toggle helpers and derived data** + +After the existing `toggleModel` function, add: + +```typescript + const toggleDept = (dept: string) => { + const newSet = new Set(expandedDepts); + if (newSet.has(dept)) newSet.delete(dept); + else newSet.add(dept); + setExpandedDepts(newSet); + }; + + const toggleManagerDetails = (manager: string) => { + const newSet = new Set(expandedManagerDetails); + if (newSet.has(manager)) newSet.delete(manager); + else newSet.add(manager); + setExpandedManagerDetails(newSet); + }; + + const toggleRegion = (region: string) => { + const newSet = new Set(expandedRegions); + if (newSet.has(region)) newSet.delete(region); + else newSet.add(region); + setExpandedRegions(newSet); + }; + + const toggleCustomer = (customer: string) => { + const newSet = new Set(expandedCustomers); + if (newSet.has(customer)) newSet.delete(customer); + else newSet.add(customer); + setExpandedCustomers(newSet); + }; + + // Derived data for dept section + const allManagersList = deptData.flatMap((d) => d.managers.map((m) => m.manager)).filter((v, i, a) => a.indexOf(v) === i).sort(); + const managerStats = deptData + .flatMap((d) => d.managers) + .filter((m) => selectedManager === 'All' || m.manager === selectedManager) + .sort((a, b) => b.total - a.total); + + // Derived data for customer section + const filteredCustomerStats = customerData.filter((s) => { + const mc = !customerFilters.customer || s.customer.toLowerCase().includes(customerFilters.customer.toLowerCase()); + const mb = !customerFilters.brand || s.brand === customerFilters.brand; + const md = !customerFilters.department || s.department === customerFilters.department; + const mm = !customerFilters.manager || s.manager.toLowerCase().includes(customerFilters.manager.toLowerCase()); + const mr = !customerFilters.region || s.region === customerFilters.region; + return mc && mb && md && mm && mr; + }); + const uniqueBrands = Array.from(new Set(customerData.map((s) => s.brand).filter(Boolean))); + const uniqueDepts = Array.from(new Set(customerData.map((s) => s.department).filter(Boolean))); + const uniqueRegions = Array.from(new Set(customerData.map((s) => s.region))); + const uniqueCities = Array.from(new Set(customerData.map((s) => s.city).filter(Boolean))); + + // Derived data for region section + const filteredRegionData = regionData.filter((r) => !regionFilters.region || r.region === regionFilters.region); +``` + +- [ ] **Step 7: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 8: Commit** + +```bash +git add src/App.tsx +git commit -m "feat: add state, data loading, and helpers for 3 new modules" +``` + +--- + +### Task 6: Frontend — Department Operations UI + +**Files:** +- Modify: `src/App.tsx` (add section after the asset summary table's closing ``, before the Plate Number Modal) + +- [ ] **Step 1: Add the department operations section** + +Insert the department operations section JSX. Reference: lnoneos lines 1362-1880. This section goes right after the closing `` of the asset summary table (`bg-white rounded-sm border...`) and before `{/* Plate Number Modal */}`. + +The section includes: +1. Header with title "部门运营统计" +2. Dark summary bar (总资产/运营中/闲置中 — skip 平均出勤) +3. Toggle buttons (按部门 / 按业务员) + manager filter dropdown +4. Desktop table view (`hidden lg:block`) + - Department mode: department rows expandable to show manager cards with 6 vehicle type cells + - Manager mode: flat manager rows expandable to show 6 vehicle type cells +5. Mobile card view (`lg:hidden`) + +Port the JSX from lnoneos lines 1362-1880, replacing: +- `MOCK_DEPT_STATS` → `deptData` +- `DEPT_TOTALS.total` → `deptData.reduce((s, d) => s + d.totalAssets, 0)` +- `allManagersList` / `managerStats` / `deptViewMode` / `expandedDepts` / `expandedManagerDetails` / `selectedManager` → already defined in Task 5 +- `setShowPlateNumbers` calls: keep the same structure but remove `source` field (not needed in ln-bi) +- Remove `ArrowRightLeft` icon usage in toggle buttons — replace with simple text button +- All `rounded-2xl` → `rounded-sm` to match ln-bi style +- All `shadow-sm` stay as is + +The `setShowPlateNumbers` calls from lnoneos use `manager`, `type`, `isColdChain`, `isTrailer` fields which we added to the state type in Task 5. + +- [ ] **Step 2: Verify TypeScript compiles and build passes** + +Run: `npx tsc --noEmit && npx vite build` +Expected: no errors, successful build + +- [ ] **Step 3: Commit** + +```bash +git add src/App.tsx +git commit -m "feat: add department operations statistics section" +``` + +--- + +### Task 7: Frontend — Region Operations UI + +**Files:** +- Modify: `src/App.tsx` (add section after department section, before Plate Number Modal) + +- [ ] **Step 1: Add the region operations section** + +Reference: lnoneos lines 1882-2174. Insert after the department section. + +The section includes: +1. Slate-themed header with "区域运营统计" + filter button +2. Filter popover (客户搜索 / 区域下拉 / 城市下拉) +3. Desktop table: expandable region rows → vehicle type sub-rows +4. Mobile cards: expandable region cards with type breakdown + +Port the JSX from lnoneos, replacing: +- `MOCK_CUSTOMER_STATS` region-based filtering → use `filteredRegionData` (from Task 5) +- Region stats aggregation in lnoneos used mock data with `Math.floor(totalAssets * 0.8)` for operating — use real `r.operatingCount` and `r.inventoryCount` +- Type breakdown: use `r.typeBreakdown` array from API +- `uniqueRegions` / `uniqueCities` → already defined in Task 5 +- `setShowPlateNumbers` calls: use `vehicleType` field for type filtering instead of lnoneos's `type` field +- `rounded-2xl` → `rounded-sm` +- Filter popover for cities: derive from `regionData` (all unique cities from customers) + +Note: The region filter's city dropdown needs city data. Add to Task 5's derived data if not already there. The regionData from API contains customer names but not cities. For the city filter, we can derive from customerData filtered by region. + +- [ ] **Step 2: Verify TypeScript compiles and build passes** + +Run: `npx tsc --noEmit && npx vite build` +Expected: no errors, successful build + +- [ ] **Step 3: Commit** + +```bash +git add src/App.tsx +git commit -m "feat: add region operations statistics section" +``` + +--- + +### Task 8: Frontend — Customer Operations UI + +**Files:** +- Modify: `src/App.tsx` (add section after region section, before Plate Number Modal) + +- [ ] **Step 1: Add the customer operations section** + +Reference: lnoneos lines 2176-2496. Insert after the region section. + +The section includes: +1. Emerald-themed header with "客户运营统计" + filter button +2. Filter popover (客户名搜索 / 业务员搜索 / 品牌下拉 / 部门下拉 / 区域下拉) +3. Desktop table: customer rows with 6 vehicle type columns + total, expandable detail cards +4. Mobile cards: customer cards with expandable vehicle type grid + +Port the JSX from lnoneos, replacing: +- `MOCK_CUSTOMER_STATS` → `filteredCustomerStats` (from Task 5) +- `DEPT_TOTALS.total` → `customerData.reduce((s, c) => s + c.total, 0)` for asset ratio +- `setShowPlateNumbers` calls: use `vehicleType` + `customer` fields +- `uniqueBrands` / `uniqueDepts` / `uniqueRegions` → already defined in Task 5 +- `rounded-2xl` → `rounded-sm` + +- [ ] **Step 2: Verify TypeScript compiles and build passes** + +Run: `npx tsc --noEmit && npx vite build` +Expected: no errors, successful build + +- [ ] **Step 3: Commit** + +```bash +git add src/App.tsx +git commit -m "feat: add customer operations statistics section" +``` + +--- + +### Task 9: Final verification and build + +**Files:** All modified files + +- [ ] **Step 1: Full TypeScript check** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 2: Production build** + +Run: `npx vite build` +Expected: successful build with no warnings + +- [ ] **Step 3: Verify all sections render** + +Run: `npm run dev` and check: +- Department section loads with real data +- Region section loads with real data +- Customer section loads with real data +- Filter popovers work +- Expand/collapse works +- Click-through to plate number modal works + +- [ ] **Step 4: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "fix: address any issues from final review" +``` diff --git a/docs/superpowers/plans/2026-04-02-mileage-backend-refactor.md b/docs/superpowers/plans/2026-04-02-mileage-backend-refactor.md new file mode 100644 index 0000000..db9cd25 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-mileage-backend-refactor.md @@ -0,0 +1,926 @@ +# Mileage Backend Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor `src/server/routes/mileage.ts` (569 lines) into well-typed, modular files with clear responsibilities, eliminating duplicate logic and `as any` casts. + +**Architecture:** Split the monolithic route file into: shared types, a reusable vehicle-info query module, a monitoring cache module, and focused route handlers. The API contract (request/response shapes) stays identical — this is a pure internal refactor with zero frontend changes. + +**Tech Stack:** Hono, mysql2/promise, TypeScript strict types + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| `src/server/routes/mileage/types.ts` | All interfaces for mileage domain (cache, vehicles, filters, API responses) | +| `src/server/routes/mileage/vehicle-info.ts` | Shared SQL + helper to build plate→info Map from `lingniu_prod` | +| `src/server/routes/mileage/cache.ts` | Monitoring cache: refresh logic, data merging, filter precomputation, target mapping | +| `src/server/routes/mileage/monitoring.ts` | `GET /monitoring` route handler | +| `src/server/routes/mileage/targets.ts` | `GET /targets`, `GET /target/:id/vehicles` route handlers | +| `src/server/routes/mileage/trend.ts` | `GET /trend` route handler | +| `src/server/routes/mileage/index.ts` | Hono app assembly: imports routes, starts cache timer, exports app | + +After refactor, delete: `src/server/routes/mileage.ts` (the old monolith). + +## Constraints + +- **Zero API changes** — all request params and response JSON shapes must remain identical +- **Zero frontend changes** — `src/modules/mileage/api.ts` and `types.ts` stay untouched +- **Preserve all existing behavior** including cache refresh interval, date queries, filter logic + +--- + +### Task 1: Create shared types + +**Files:** +- Create: `src/server/routes/mileage/types.ts` + +- [ ] **Step 1: Create the types file** + +```typescript +// src/server/routes/mileage/types.ts + +/** 缓存中的单辆车数据 */ +export interface CachedVehicle { + plate: string; + vin: string; + dailyKm: number; + totalKm: number | null; + source: string; + isOnline: boolean; + isDataSynced: boolean; + customer: string | null; + department: string | null; + manager: string | null; + rentStatus: string | null; + entity: string | null; + project: string | null; + yesterdayKm: number; +} + +/** 车牌前缀统计 */ +export interface PlatePrefix { + prefix: string; + count: number; +} + +/** 筛选选项(前端下拉) */ +export interface MonitoringFilters { + departments: string[]; + customers: string[]; + plates: string[]; + projects: string[]; + entities: string[]; + rentStatuses: string[]; + platePrefixes: PlatePrefix[]; + targetNames: string[]; +} + +/** 监控缓存 */ +export interface MonitoringCache { + vehicles: CachedVehicle[]; + stats: { totalToday: number; totalAll: number; vehicleCount: number }; + filters: MonitoringFilters; + targetPlatesMap: Map>; + updatedAt: string; +} + +/** /monitoring 响应中的统计 */ +export interface MonitoringStats { + totalToday: number; + totalAll: number; + vehicleCount: number; + yesterdayTotal: number; +} + +/** /monitoring 完整响应 */ +export interface MonitoringResponse { + vehicles: CachedVehicle[]; + stats: MonitoringStats; + filters: MonitoringFilters; + total: number; + page: number; + totalPages: number; + updatedAt: string; +} + +/** 车辆关联信息(从 lingniu_prod 查出的原始行) */ +export interface VehicleInfoRow { + plate: string; + customer: string | null; + department: string | null; + manager: string | null; + rent_status: string | null; + entity: string | null; + project: string | null; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors (new file has no imports/consumers yet) + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/mileage/types.ts +git commit -m "refactor: extract mileage shared types" +``` + +--- + +### Task 2: Extract vehicle-info query module + +**Files:** +- Create: `src/server/routes/mileage/vehicle-info.ts` + +- [ ] **Step 1: Create the vehicle-info module** + +This extracts the `VEHICLE_INFO_SQL` constant and a helper function to build the info Map. Both the cache builder and the `/target/:id/vehicles` route reuse this. + +```typescript +// src/server/routes/mileage/vehicle-info.ts +import pool from '../../db.js'; +import type { VehicleInfoRow } from './types.js'; + +/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目) */ +export const VEHICLE_INFO_SQL = `SELECT + truck.plate_number AS plate, + cus.customer_name AS customer, + dep.dep_name AS department, + u.user_name AS manager, + dic_status.dic_name AS rent_status, + org_truck.org_name AS entity, + c.project_name AS project +FROM tab_truck truck +LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0 +LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0 +LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0 +LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0 +LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0 +LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status' + AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0 +LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0 +WHERE truck.is_deleted = 0 AND truck.is_operation = 1`; + +/** 查询所有车辆关联信息,返回 plate→info 的 Map */ +export async function fetchVehicleInfoMap(): Promise> { + const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown]; + const map = new Map(); + for (const row of rows) { + map.set(row.plate, row); + } + return map; +} + +/** 查询指定车牌的关联信息 */ +export async function fetchVehicleInfoByPlates(plates: string[]): Promise> { + if (plates.length === 0) return new Map(); + const [rows] = await pool.execute( + `${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`, + plates + ) as [VehicleInfoRow[], unknown]; + const map = new Map(); + for (const row of rows) { + map.set(row.plate, row); + } + return map; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/mileage/vehicle-info.ts +git commit -m "refactor: extract vehicle-info query module" +``` + +--- + +### Task 3: Extract monitoring cache module + +**Files:** +- Create: `src/server/routes/mileage/cache.ts` + +- [ ] **Step 1: Create the cache module** + +This contains the cache singleton, refresh logic, and the `queryDateMileage` function. Both used to live in the monolith. + +```typescript +// src/server/routes/mileage/cache.ts +import pool from '../../db.js'; +import mileagePool from '../../mileage-db.js'; +import { fetchVehicleInfoMap } from './vehicle-info.js'; +import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix } from './types.js'; + +let monitoringCache: MonitoringCache | null = null; + +export function getCache(): MonitoringCache | null { + return monitoringCache; +} + +/** 部门排序顺序 */ +const DEPT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; + +function sortDepartments(departments: string[]): string[] { + return departments.sort((a, b) => { + const ai = DEPT_ORDER.findIndex(d => a.includes(d)); + const bi = DEPT_ORDER.findIndex(d => b.includes(d)); + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); + }); +} + +/** 从车辆列表计算筛选选项 */ +function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): MonitoringFilters { + const departments = sortDepartments( + Array.from(new Set(vehicles.map(v => v.department).filter((d): d is string => d !== null))) + ); + const customers = Array.from(new Set(vehicles.map(v => v.customer).filter((c): c is string => c !== null))); + const plates = vehicles.map(v => v.plate); + const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null))); + const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null))); + const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null))); + + const prefixCount = new Map(); + for (const v of vehicles) { + const p = v.plate.charAt(0); + prefixCount.set(p, (prefixCount.get(p) || 0) + 1); + } + const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries()) + .map(([prefix, count]) => ({ prefix, count })) + .sort((a, b) => b.count - a.count); + + return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames }; +} + +/** 将里程原始行 + 车辆信息合并为 CachedVehicle 列表 */ +function mergeVehicles( + mileageRows: { plate: string; vin: string; daily_km: string; total_km: string | null; source: string }[], + infoMap: Map, + yesterdayMap: Map, +): CachedVehicle[] { + // 去重:同一 plate 取 daily_km 最大的 + const mileageMap = new Map(); + for (const row of mileageRows) { + const existing = mileageMap.get(row.plate); + if (!existing || Number(row.daily_km) > Number(existing.daily_km)) { + mileageMap.set(row.plate, row); + } + } + + return Array.from(mileageMap.values()).map(m => { + const info = infoMap.get(m.plate); + const dailyKm = Number(m.daily_km) || 0; + const source = m.source || 'NONE'; + return { + plate: m.plate, + vin: m.vin, + dailyKm, + totalKm: m.total_km !== null ? Number(m.total_km) : null, + source, + isOnline: source !== 'NONE' && dailyKm > 0, + isDataSynced: source !== 'NONE', + customer: info?.customer || null, + department: info?.department || null, + manager: info?.manager || null, + rentStatus: info?.rent_status || null, + entity: info?.entity || null, + project: info?.project || null, + yesterdayKm: yesterdayMap.get(m.plate) || 0, + }; + }); +} + +/** 刷新监控缓存(从两个数据库并行查询) */ +export async function refreshMonitoringCache(): Promise { + try { + console.log('[mileage] refreshing monitoring cache...'); + const start = Date.now(); + + const [mileageRows, yesterdayMap, infoMap, targetRows] = await Promise.all([ + // 最新日期的里程数据 + (async () => { + const [dateRows] = await mileagePool.execute( + 'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats' + ) as [{ latest: string | null }[], unknown]; + const latestDate = dateRows[0]?.latest; + if (!latestDate) return []; + const [rows] = await mileagePool.execute( + 'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?', + [latestDate] + ) as [any[], unknown]; + return rows; + })(), + // 昨日里程(用于环比) + (async () => { + const [rows] = await mileagePool.execute( + `SELECT plate, daily_km FROM v_vehicle_daily_stats + WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)` + ) as [any[], unknown]; + const map = new Map(); + for (const r of rows) { + const km = Number(r.daily_km) || 0; + const existing = map.get(r.plate) || 0; + if (km > existing) map.set(r.plate, km); + } + return map; + })(), + // 车辆关联信息 + fetchVehicleInfoMap(), + // 考核批次→车牌映射 + pool.execute( + `SELECT t.id, t.target_name, v.plate_number + FROM tab_mileage_assessment_target t + JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0 + WHERE t.is_deleted = 0` + ).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]), + ]); + + // 构建批次映射 + const targetPlatesMap = new Map>(); + for (const r of targetRows) { + const set = targetPlatesMap.get(r.target_name) || new Set(); + set.add(r.plate_number); + targetPlatesMap.set(r.target_name, set); + } + const targetNames = Array.from(targetPlatesMap.keys()); + + const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap); + + const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0); + const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0); + + monitoringCache = { + vehicles, + stats: { totalToday, totalAll, vehicleCount: vehicles.length }, + filters: buildFilters(vehicles, targetNames), + targetPlatesMap, + updatedAt: new Date().toISOString(), + }; + + console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`); + } catch (e: unknown) { + console.error('[mileage] cache refresh error:', e); + } +} + +/** 查询指定日期的里程数据(不使用缓存) */ +export async function queryDateMileage(dateStr: string): Promise { + const [mileageRows, yesterdayRows, infoMap] = await Promise.all([ + mileagePool.execute( + 'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?', + [dateStr] + ).then(([r]) => r as any[]), + mileagePool.execute( + 'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)', + [dateStr] + ).then(([r]) => r as any[]), + fetchVehicleInfoMap(), + ]); + + const yesterdayMap = new Map(); + for (const r of yesterdayRows) { + const km = Number(r.daily_km) || 0; + const existing = yesterdayMap.get(r.plate) || 0; + if (km > existing) yesterdayMap.set(r.plate, km); + } + + return mergeVehicles(mileageRows, infoMap, yesterdayMap); +} + +/** 构建指定日期数据的筛选选项 */ +export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters { + return buildFilters(vehicles, monitoringCache?.filters.targetNames || []); +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/mileage/cache.ts +git commit -m "refactor: extract monitoring cache module" +``` + +--- + +### Task 4: Create monitoring route handler + +**Files:** +- Create: `src/server/routes/mileage/monitoring.ts` + +- [ ] **Step 1: Create the monitoring route** + +```typescript +// src/server/routes/mileage/monitoring.ts +import { Hono } from 'hono'; +import { getCache, queryDateMileage, buildDateFilters } from './cache.js'; +import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js'; + +const app = new Hono(); + +const EMPTY_RESPONSE: MonitoringResponse = { + vehicles: [], + stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }, + filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] }, + total: 0, + page: 1, + totalPages: 1, + updatedAt: new Date().toISOString(), +}; + +/** 应用筛选条件 */ +function applyFilters(vehicles: CachedVehicle[], params: { + search: string; dept: string; customer: string; project: string; + entity: string; rentStatus: string; plate: string; platePrefix: string; + targetName: string; mileageMin: string; mileageMax: string; +}): CachedVehicle[] { + let result = vehicles; + + if (params.search) { + const q = params.search.toLowerCase(); + result = result.filter(v => + v.plate.toLowerCase().includes(q) || + (v.customer || '').toLowerCase().includes(q) || + (v.project || '').toLowerCase().includes(q) + ); + } + if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept); + if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer); + if (params.project) result = result.filter(v => v.project === params.project); + if (params.entity) result = result.filter(v => v.entity === params.entity); + if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus); + if (params.plate) result = result.filter(v => v.plate === params.plate); + if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix)); + if (params.targetName) { + const cache = getCache(); + const tPlates = cache?.targetPlatesMap.get(params.targetName); + result = tPlates ? result.filter(v => tPlates.has(v.plate)) : []; + } + if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin)); + if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax)); + + return result; +} + +app.get('/', async (c) => { + const sortBy = c.req.query('sortBy') || 'today'; + const sortOrder = c.req.query('sortOrder') || 'desc'; + const limit = Number(c.req.query('limit')) || 50; + const page = Number(c.req.query('page')) || 1; + const date = c.req.query('date') || ''; + + const filterParams = { + search: c.req.query('search') || '', + dept: c.req.query('dept') || '', + customer: c.req.query('customer') || '', + project: c.req.query('project') || '', + entity: c.req.query('entity') || '', + rentStatus: c.req.query('rentStatus') || '', + plate: c.req.query('plate') || '', + platePrefix: c.req.query('platePrefix') || '', + targetName: c.req.query('targetName') || '', + mileageMin: c.req.query('mileageMin') || '', + mileageMax: c.req.query('mileageMax') || '', + }; + + // 获取数据源 + let allVehicles: CachedVehicle[]; + let filters: MonitoringFilters; + + if (date) { + try { + allVehicles = await queryDateMileage(date); + filters = buildDateFilters(allVehicles); + } catch (e: unknown) { + console.error('monitoring date query error:', e); + return c.json(EMPTY_RESPONSE, 500); + } + } else { + const cache = getCache(); + if (!cache) return c.json(EMPTY_RESPONSE); + allVehicles = cache.vehicles; + filters = cache.filters; + } + + // 筛选 + const filtered = applyFilters(allVehicles, filterParams); + + // 统计 + const stats = { + totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0), + totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0), + vehicleCount: filtered.length, + yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0), + }; + + // 排序 + const sorted = [...filtered].sort((a, b) => { + const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0); + const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0); + return sortOrder === 'desc' ? valB - valA : valA - valB; + }); + + // 分页 + const offset = (page - 1) * limit; + const paged = sorted.slice(offset, offset + limit); + const total = filtered.length; + + return c.json({ + vehicles: paged, + stats, + filters, + total, + page, + totalPages: Math.ceil(total / limit), + updatedAt: date || getCache()?.updatedAt || new Date().toISOString(), + }); +}); + +export default app; +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/mileage/monitoring.ts +git commit -m "refactor: create monitoring route handler" +``` + +--- + +### Task 5: Create targets route handler + +**Files:** +- Create: `src/server/routes/mileage/targets.ts` + +- [ ] **Step 1: Create the targets route** + +```typescript +// src/server/routes/mileage/targets.ts +import { Hono } from 'hono'; +import pool from '../../db.js'; +import mileagePool from '../../mileage-db.js'; +import { getCache } from './cache.js'; +import { fetchVehicleInfoByPlates } from './vehicle-info.js'; + +const app = new Hono(); + +// GET /targets — 考核项目列表 + 汇总 +app.get('/', async (c) => { + try { + const [targets] = await pool.execute( + 'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id' + ) as [any[], unknown]; + + const [vehicleStats] = await pool.execute(` + SELECT + target_id, COUNT(*) as total, + SUM(today_mileage) as today_total, + SUM(current_mileage) as cumulative_total, + AVG(current_year_completion_rate) as avg_completion, + SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count, + SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count, + SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count, + SUM(current_year_mileage_task) as current_year_target, + SUM(current_year_mileage) as current_year_completed, + MAX(current_year_assessment_end_date) as year_end_date + FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0 + GROUP BY target_id + `) as [any[], unknown]; + + const statsMap = new Map(); + for (const s of vehicleStats) statsMap.set(s.target_id, s); + + const [periodRows] = await pool.execute(` + SELECT target_id, + DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date, + DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date, + COUNT(*) as cnt + FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0 + GROUP BY target_id, assessment_start_date, assessment_end_date + ORDER BY target_id, assessment_start_date + `) as [any[], unknown]; + + const periodsMap = new Map(); + for (const p of periodRows) { + const list = periodsMap.get(p.target_id) || []; + list.push(`${p.start_date} ~ ${p.end_date} (${p.cnt}台)`); + periodsMap.set(p.target_id, list); + } + + // 使用监控缓存里程数据(与里程看板一致) + const cache = getCache(); + const cacheVehicleMap = new Map(); + if (cache) { + for (const v of cache.vehicles) { + cacheVehicleMap.set(v.plate, Math.max(0, v.dailyKm || 0)); + } + } + + const [targetVehicleRows] = await pool.execute( + 'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0' + ) as [{ target_id: number; plate_number: string }[], unknown]; + + const targetIdPlatesMap = new Map(); + for (const r of targetVehicleRows) { + const list = targetIdPlatesMap.get(r.target_id) || []; + list.push(r.plate_number); + targetIdPlatesMap.set(r.target_id, list); + } + + const now = new Date(); + const result = targets.map((t: any) => { + const s = statsMap.get(t.id) || {}; + const currentYearTarget = Number(s.current_year_target) || 0; + const currentYearCompleted = Number(s.current_year_completed) || 0; + const remaining = Math.max(0, currentYearTarget - currentYearCompleted); + const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now; + const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000)); + const dailyTarget = remaining / daysLeft; + + const periods = periodsMap.get(t.id) || []; + if (periods.length === 0) { + const startDate = t.default_start_date ? new Date(t.default_start_date).toISOString().split('T')[0] : ''; + const endDate = t.default_end_date ? new Date(t.default_end_date).toISOString().split('T')[0] : ''; + if (startDate || endDate) periods.push(`${startDate} ~ ${endDate}`); + } + + return { + id: t.id, + targetName: t.target_name, + vehicleCount: Number(s.total) || t.vehicle_count, + totalMileagePerVehicle: Number(t.total_mileage_per_vehicle), + annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle), + assessmentYears: t.assessment_years, + periods, + todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0), + cumulativeTotal: Number(s.cumulative_total) || 0, + avgCompletion: (Number(s.avg_completion) || 0) * 100, + qualifiedCount: Number(s.qualified_count) || 0, + yearQualifiedCount: Number(s.year_qualified_count) || 0, + halfQualifiedCount: Number(s.half_qualified_count) || 0, + currentYearTarget, + currentYearCompleted, + remaining, + daysLeft, + dailyTarget: Math.round(dailyTarget * 10) / 10, + }; + }); + + return c.json(result); + } catch (e: unknown) { + console.error('targets error:', e); + return c.json([], 500); + } +}); + +// GET /target/:id/vehicles — 某项目的车辆明细 +app.get('/:id/vehicles', async (c) => { + const targetId = c.req.param('id'); + const date = c.req.query('date') || ''; + + try { + const [rows] = await pool.execute( + `SELECT plate_number, today_mileage, vehicle_total_mileage, + completion_rate, is_qualified, current_year_is_qualified, + daily_required_mileage + FROM tab_mileage_assessment_vehicle + WHERE target_id = ? AND is_deleted = 0 + ORDER BY today_mileage DESC`, + [targetId] + ) as [any[], unknown]; + + const plates: string[] = rows.map((r: any) => r.plate_number); + const infoMap = await fetchVehicleInfoByPlates(plates); + + // 指定日期时,从里程库查该日里程 + const dateMileageMap = new Map(); + if (date && plates.length > 0) { + const [mileageRows] = await mileagePool.execute( + `SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats + WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`, + [date, ...plates] + ) as [any[], unknown]; + for (const m of mileageRows) { + const existing = dateMileageMap.get(m.plate); + const dailyKm = Number(m.daily_km) || 0; + if (!existing || dailyKm > existing.dailyKm) { + const source = m.source || 'NONE'; + dateMileageMap.set(m.plate, { + dailyKm, + totalKm: m.total_km !== null ? Number(m.total_km) : null, + isOnline: source !== 'NONE' && dailyKm > 0, + }); + } + } + } + + const result = rows.map((r: any) => { + const info = infoMap.get(r.plate_number); + const dateMileage = date ? dateMileageMap.get(r.plate_number) : null; + return { + plateNumber: r.plate_number, + todayMileage: dateMileage ? dateMileage.dailyKm : (Number(r.today_mileage) || 0), + totalMileage: dateMileage?.totalKm ?? (Number(r.vehicle_total_mileage) || 0), + completionRate: Number(r.completion_rate) || 0, + isQualified: r.is_qualified === 1, + currentYearIsQualified: r.current_year_is_qualified === 1, + dailyRequiredMileage: Number(r.daily_required_mileage) || 0, + rentStatus: info?.rent_status || null, + department: info?.department || null, + customer: info?.customer || null, + isOnline: dateMileage ? dateMileage.isOnline : true, + }; + }); + + return c.json(result); + } catch (e: unknown) { + console.error('target vehicles error:', e); + return c.json([], 500); + } +}); + +export default app; +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/mileage/targets.ts +git commit -m "refactor: create targets route handler" +``` + +--- + +### Task 6: Create trend route handler + +**Files:** +- Create: `src/server/routes/mileage/trend.ts` + +- [ ] **Step 1: Create the trend route** + +```typescript +// src/server/routes/mileage/trend.ts +import { Hono } from 'hono'; +import pool from '../../db.js'; +import mileagePool from '../../mileage-db.js'; + +const app = new Hono(); + +app.get('/', async (c) => { + const targetId = c.req.query('targetId'); + const days = Number(c.req.query('days')) || 7; + + try { + let plates: string[] = []; + if (targetId) { + const [vehicleRows] = await pool.execute( + 'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0', + [targetId] + ) as [{ plate_number: string }[], unknown]; + plates = vehicleRows.map(r => r.plate_number); + if (plates.length === 0) return c.json([]); + } + + let sql = ` + SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage + FROM v_vehicle_daily_stats + WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE() + `; + const params: (string | number)[] = [days]; + + if (plates.length > 0) { + sql += ` AND plate IN (${plates.map(() => '?').join(',')})`; + params.push(...plates); + } + + sql += ' GROUP BY stat_date ORDER BY stat_date'; + + const [rows] = await mileagePool.execute(sql, params) as [any[], unknown]; + + return c.json(rows.map((r: any) => ({ + date: r.date, + mileage: Math.round(Number(r.mileage) || 0), + }))); + } catch (e: unknown) { + console.error('trend error:', e); + return c.json([], 500); + } +}); + +export default app; +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/mileage/trend.ts +git commit -m "refactor: create trend route handler" +``` + +--- + +### Task 7: Assemble new index and swap in + +**Files:** +- Create: `src/server/routes/mileage/index.ts` +- Delete: `src/server/routes/mileage.ts` + +- [ ] **Step 1: Create the new index** + +```typescript +// src/server/routes/mileage/index.ts +import { Hono } from 'hono'; +import { refreshMonitoringCache } from './cache.js'; +import monitoringRouter from './monitoring.js'; +import targetsRouter from './targets.js'; +import trendRouter from './trend.js'; + +const app = new Hono(); + +app.route('/monitoring', monitoringRouter); +app.route('/targets', targetsRouter); +app.route('/target', targetsRouter); +app.route('/trend', trendRouter); + +// 启动时立即刷新缓存,之后每分钟刷新 +refreshMonitoringCache(); +setInterval(refreshMonitoringCache, 60 * 1000); + +export default app; +``` + +- [ ] **Step 2: Delete the old monolith** + +```bash +rm src/server/routes/mileage.ts +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 4: Verify the server starts and API works** + +Run: `npm run dev` and test: +- `curl http://localhost:3001/api/mileage/monitoring?limit=2` — should return vehicles +- `curl http://localhost:3001/api/mileage/targets` — should return target list +- `curl http://localhost:3001/api/mileage/trend?days=7` — should return trend data + +- [ ] **Step 5: Commit** + +```bash +git add src/server/routes/mileage/ && git add -u src/server/routes/mileage.ts +git commit -m "refactor: replace mileage monolith with modular route files" +``` + +--- + +### Task 8: Fix the stale comment and final cleanup + +**Files:** +- Modify: `src/server/routes/mileage/cache.ts` + +- [ ] **Step 1: Verify no leftover references to old file** + +Run: `grep -r "routes/mileage.js" src/` — should only find `src/server/index.ts` which imports `./routes/mileage.js`. Since we moved to `mileage/index.ts`, the import path `./routes/mileage.js` resolves to `./routes/mileage/index.js` automatically. No change needed. + +- [ ] **Step 2: Verify full build** + +Run: `npx tsc --noEmit && npm run build` +Expected: no errors + +- [ ] **Step 3: Final commit** + +```bash +git commit --allow-empty -m "refactor: mileage backend refactor complete — verified build" +``` diff --git a/docs/superpowers/plans/2026-04-16-smart-scheduling.md b/docs/superpowers/plans/2026-04-16-smart-scheduling.md new file mode 100644 index 0000000..3d53614 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-smart-scheduling.md @@ -0,0 +1,1615 @@ +# Smart Scheduling (智能调度) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a smart scheduling module that analyzes vehicle mileage assessment data, generates vehicle replacement suggestions using a greedy priority-matching algorithm, and presents an actionable UI for dispatchers. + +**Architecture:** Backend Hono routes compute suggestions in real-time by aggregating assessment targets, vehicle mileage, customer daily averages, inventory vehicles, and GPS location data. Frontend React module displays suggestions with batch filtering, priority-sorted list, and a detail modal with before/after comparison. A notify endpoint records dispatcher actions and triggers external callbacks. + +**Tech Stack:** Hono (backend), MySQL (mysql2/promise), React 19, Tailwind CSS, motion/react, lucide-react, recharts (optional for future charts) + +**Spec:** `docs/superpowers/specs/2026-04-16-smart-scheduling-design.md` + +--- + +## File Structure + +### Backend (new files) + +| File | Responsibility | +|------|---------------| +| `src/server/routes/scheduling/index.ts` | Router entry — mounts suggestions + notify sub-routes | +| `src/server/routes/scheduling/suggestions.ts` | `GET /` — queries all data sources, runs classification + matching algorithm, returns `SchedulingResponse` | +| `src/server/routes/scheduling/notify.ts` | `POST /notify` — records action, triggers callback, returns success | +| `src/server/routes/scheduling/types.ts` | All scheduling-specific TypeScript interfaces | +| `src/server/routes/scheduling/algorithm.ts` | Pure functions: classifyVehicles, generateSuggestions, vehicle-type matching, region matching | + +### Backend (modified files) + +| File | Change | +|------|--------| +| `src/server/index.ts` | Add `import schedulingRouter` and `app.route('/api/scheduling', schedulingRouter)` | +| `src/server/routes/vehicles.ts` | Export `mapRegion()` function (currently module-private) | + +### Frontend (new files) + +| File | Responsibility | +|------|---------------| +| `src/modules/scheduling/types.ts` | Frontend type definitions mirroring backend response | +| `src/modules/scheduling/api.ts` | `fetchSuggestions()`, `sendNotify()` client functions | +| `src/modules/scheduling/SchedulingModule.tsx` | Main entry — state management, data loading, batch filter, stats cards, list + detail | +| `src/modules/scheduling/SuggestionList.tsx` | Renders priority-sorted suggestion cards | +| `src/modules/scheduling/SuggestionDetail.tsx` | Modal with current vehicle info, candidate list with before/after comparison, notify button | + +### Frontend (modified files) + +| File | Change | +|------|--------| +| `src/App.tsx` | Add scheduling module to `MODULES` array | + +--- + +## Task 1: Backend Types + +**Files:** +- Create: `src/server/routes/scheduling/types.ts` + +- [ ] **Step 1: Create the scheduling types file** + +```typescript +// src/server/routes/scheduling/types.ts + +export interface SchedulingVehicleInfo { + plateNumber: string; + targetId: number; + targetName: string; + vehicleType: string; // "4.5T冷链" | "4.5T普货" | "18T" | "49T" | "挂车" + totalMileage: number; + completionRate: number; // 0-1 + yearTarget: number; + region: string; // mapped region: "嘉兴" | "广东" | "北京" | "新疆" | "其他" + province: string; + customer: string | null; + customerAvgDaily: number; + predictedYearEnd: number; + daysLeft: number; +} + +export interface CandidateVehicle { + plateNumber: string; + targetId: number | null; + targetName: string | null; + vehicleType: string; + totalMileage: number; + completionRate: number; + yearTarget: number | null; + region: string; + province: string; + mileageGap: number; + predictedAfterSwap: number; + canQualifyAfterSwap: boolean; +} + +export interface SchedulingSuggestion { + id: string; + priority: 'high' | 'medium'; + type: 'replace_qualified' | 'rescue_hopeless'; + currentVehicle: SchedulingVehicleInfo; + candidates: CandidateVehicle[]; + reason: string; +} + +export interface SchedulingSummary { + qualifiedCount: number; + hopelessCount: number; + suggestionCount: number; + estimatedGain: number; +} + +export interface SchedulingTargetOption { + id: number; + name: string; + vehicleCount: number; +} + +export interface SchedulingResponse { + summary: SchedulingSummary; + suggestions: SchedulingSuggestion[]; + targets: SchedulingTargetOption[]; +} + +export interface NotifyRequest { + suggestionId: string; + currentPlate: string; + candidatePlate: string; +} + +/** Classification of a vehicle's qualification likelihood */ +export type VehicleClassification = 'qualified' | 'hopeless' | 'normal'; + +/** Internal enriched vehicle used during algorithm computation */ +export interface EnrichedVehicle { + plateNumber: string; + targetId: number; + targetName: string; + vehicleType: string; + totalMileage: number; + currentYearMileage: number; + completionRate: number; + yearTarget: number; + isQualified: boolean; + currentYearIsQualified: boolean; + dailyRequiredMileage: number; + region: string; + province: string; + customer: string | null; + customerAvgDaily: number; + predictedYearEnd: number; + daysLeft: number; + classification: VehicleClassification; +} + +/** Inventory vehicle available for replacement */ +export interface InventoryVehicle { + plateNumber: string; + vehicleType: string; + region: string; + province: string; + totalMileage: number; + /** If this inventory vehicle is also in an assessment target */ + targetId: number | null; + targetName: string | null; + yearTarget: number | null; + completionRate: number; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/routes/scheduling/types.ts +git commit -m "feat(scheduling): add backend type definitions" +``` + +--- + +## Task 2: Algorithm Pure Functions + +**Files:** +- Create: `src/server/routes/scheduling/algorithm.ts` +- Modify: `src/server/routes/vehicles.ts` (export `mapRegion`) + +- [ ] **Step 1: Export mapRegion from vehicles.ts** + +In `src/server/routes/vehicles.ts`, the `mapRegion` function (line ~94) is currently not exported. Add the `export` keyword: + +```typescript +// Change: +function mapRegion(province: string | null, city: string | null): string { +// To: +export function mapRegion(province: string | null, city: string | null): string { +``` + +- [ ] **Step 2: Create algorithm.ts with vehicle type matching** + +```typescript +// src/server/routes/scheduling/algorithm.ts + +import type { + EnrichedVehicle, InventoryVehicle, SchedulingSuggestion, + CandidateVehicle, VehicleClassification, SchedulingSummary, +} from './types.js'; + +// --- Vehicle type compatibility --- + +const COLD_CHAIN_TYPES = new Set(['4.5T冷链']); +const COMPATIBLE_FOR_COLD_CHAIN = new Set(['4.5T冷链', '4.5T普货']); + +/** + * Check if candidateType can replace sourceType. + * Rules: + * - 4.5T冷链 can go to 4.5T冷链 or 4.5T普货 (cold chain can run without AC) + * - 4.5T普货 can only go to 4.5T普货 (cannot reverse into cold chain) + * - All other types: exact match only (18T↔18T, 49T↔49T, 挂车↔挂车) + */ +export function isTypeCompatible(sourceType: string, candidateType: string): boolean { + if (sourceType === candidateType) return true; + // Cold chain vehicle can replace plain cargo + if (COLD_CHAIN_TYPES.has(candidateType) && COMPATIBLE_FOR_COLD_CHAIN.has(sourceType)) return true; + return false; +} + +// --- Vehicle classification --- + +const QUALIFIED_THRESHOLD = 1.2; // 120% +const HOPELESS_THRESHOLD = 0.6; // 60% + +export function classifyVehicle( + currentYearIsQualified: boolean, + predictedYearEnd: number, + yearTarget: number, +): VehicleClassification { + if (currentYearIsQualified || (yearTarget > 0 && predictedYearEnd / yearTarget >= QUALIFIED_THRESHOLD)) { + return 'qualified'; + } + if (yearTarget > 0 && predictedYearEnd / yearTarget < HOPELESS_THRESHOLD) { + return 'hopeless'; + } + return 'normal'; +} + +// --- Suggestion generation --- + +const MAX_CANDIDATES = 5; + +export function generateSuggestions( + vehicles: EnrichedVehicle[], + inventoryVehicles: InventoryVehicle[], +): { suggestions: SchedulingSuggestion[]; summary: SchedulingSummary } { + const qualified = vehicles.filter(v => v.classification === 'qualified'); + const hopeless = vehicles.filter(v => v.classification === 'hopeless'); + + const suggestions: SchedulingSuggestion[] = []; + // Track which inventory vehicles have been used as candidates (for estimatedGain) + const usedInventory = new Set(); + + // --- Scenario B first (higher priority): rescue hopeless vehicles --- + for (const vehicle of hopeless) { + const candidates = findCandidatesForHopeless(vehicle, inventoryVehicles); + if (candidates.length === 0) continue; + + suggestions.push({ + id: `s-${vehicle.plateNumber}-${Date.now()}`, + priority: 'high', + type: 'rescue_hopeless', + currentVehicle: toVehicleInfo(vehicle), + candidates, + reason: `${vehicle.customer || '该客户'}日均里程仅 ${Math.round(vehicle.customerAvgDaily)} KM,` + + `该车达标概率 ${Math.round((vehicle.predictedYearEnd / vehicle.yearTarget) * 100)}%,` + + `建议替换为已达标车辆,将此车调配给高里程客户。`, + }); + + for (const c of candidates) usedInventory.add(c.plateNumber); + } + + // --- Scenario A: replace qualified vehicles at high-mileage customers --- + for (const vehicle of qualified) { + // Only suggest if customer avg daily is above the vehicle's required daily + if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue; + + const candidates = findCandidatesForQualified(vehicle, inventoryVehicles); + if (candidates.length === 0) continue; + + suggestions.push({ + id: `s-${vehicle.plateNumber}-${Date.now()}`, + priority: 'medium', + type: 'replace_qualified', + currentVehicle: toVehicleInfo(vehicle), + candidates, + reason: `${vehicle.customer || '该客户'}日均里程 ${Math.round(vehicle.customerAvgDaily)} KM(高里程),` + + `该车已达标(完成率 ${Math.round(vehicle.completionRate * 100)}%),` + + `建议换上里程缺口大的车辆以加速达标。`, + }); + + for (const c of candidates) usedInventory.add(c.plateNumber); + } + + // Sort: high priority first, then by predicted gap descending + suggestions.sort((a, b) => { + if (a.priority !== b.priority) return a.priority === 'high' ? -1 : 1; + return 0; + }); + + // Estimate gain: count candidates that canQualifyAfterSwap + let estimatedGain = 0; + for (const s of suggestions) { + if (s.candidates.some(c => c.canQualifyAfterSwap)) estimatedGain++; + } + + return { + suggestions, + summary: { + qualifiedCount: qualified.length, + hopelessCount: hopeless.length, + suggestionCount: suggestions.length, + estimatedGain, + }, + }; +} + +// --- Candidate finding --- + +function findCandidatesForQualified( + vehicle: EnrichedVehicle, + inventory: InventoryVehicle[], +): CandidateVehicle[] { + return inventory + .filter(iv => + isTypeCompatible(vehicle.vehicleType, iv.vehicleType) && + iv.region === vehicle.region + ) + .map(iv => { + const predictedAfterSwap = iv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft; + const gap = (iv.yearTarget ?? vehicle.yearTarget) - iv.totalMileage; + return { + plateNumber: iv.plateNumber, + targetId: iv.targetId, + targetName: iv.targetName, + vehicleType: iv.vehicleType, + totalMileage: iv.totalMileage, + completionRate: iv.completionRate, + yearTarget: iv.yearTarget, + region: iv.region, + province: iv.province, + mileageGap: Math.max(0, gap), + predictedAfterSwap: Math.round(predictedAfterSwap), + canQualifyAfterSwap: predictedAfterSwap >= (iv.yearTarget ?? vehicle.yearTarget), + }; + }) + // Prioritize: largest gap that can still qualify after swap + .sort((a, b) => { + // canQualify first + if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) return a.canQualifyAfterSwap ? -1 : 1; + // Then largest gap + return b.mileageGap - a.mileageGap; + }) + .slice(0, MAX_CANDIDATES); +} + +function findCandidatesForHopeless( + vehicle: EnrichedVehicle, + inventory: InventoryVehicle[], +): CandidateVehicle[] { + return inventory + .filter(iv => + isTypeCompatible(vehicle.vehicleType, iv.vehicleType) && + iv.region === vehicle.region && + // For hopeless: prefer already-qualified or high-mileage inventory vehicles + iv.completionRate >= 0.8 + ) + .map(iv => { + // This vehicle goes to the low-mileage customer, so predict with customer's avg + const predictedAfterSwap = iv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft; + const gap = (iv.yearTarget ?? vehicle.yearTarget) - iv.totalMileage; + return { + plateNumber: iv.plateNumber, + targetId: iv.targetId, + targetName: iv.targetName, + vehicleType: iv.vehicleType, + totalMileage: iv.totalMileage, + completionRate: iv.completionRate, + yearTarget: iv.yearTarget, + region: iv.region, + province: iv.province, + mileageGap: Math.max(0, gap), + predictedAfterSwap: Math.round(predictedAfterSwap), + canQualifyAfterSwap: iv.completionRate >= 1.0, // already qualified stays qualified + }; + }) + // Prioritize: already qualified first, then highest completion rate + .sort((a, b) => { + if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) return a.canQualifyAfterSwap ? -1 : 1; + return b.completionRate - a.completionRate; + }) + .slice(0, MAX_CANDIDATES); +} + +function toVehicleInfo(v: EnrichedVehicle) { + return { + plateNumber: v.plateNumber, + targetId: v.targetId, + targetName: v.targetName, + vehicleType: v.vehicleType, + totalMileage: v.totalMileage, + completionRate: v.completionRate, + yearTarget: v.yearTarget, + region: v.region, + province: v.province, + customer: v.customer, + customerAvgDaily: v.customerAvgDaily, + predictedYearEnd: v.predictedYearEnd, + daysLeft: v.daysLeft, + }; +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit src/server/routes/scheduling/algorithm.ts src/server/routes/scheduling/types.ts 2>&1 | head -20 +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/server/routes/scheduling/algorithm.ts src/server/routes/vehicles.ts +git commit -m "feat(scheduling): add algorithm pure functions and export mapRegion" +``` + +--- + +## Task 3: Backend Suggestions Route + +**Files:** +- Create: `src/server/routes/scheduling/suggestions.ts` + +This is the core route handler. It queries 6 data sources, enriches vehicles, then calls the algorithm. + +- [ ] **Step 1: Create suggestions.ts** + +```typescript +// src/server/routes/scheduling/suggestions.ts + +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 type { EnrichedVehicle, InventoryVehicle, SchedulingResponse } from './types.js'; +import type { AuthUser } from '../../auth/types.js'; + +const app = new Hono(); + +/** + * Classify vehicle type from model string (matches vehicles.ts logic). + * Returns a display label for the scheduling module. + */ +function classifyVehicleType(type: string, model: string): string { + if (type === '4.5T' && model.includes('冷链')) return '4.5T冷链'; + if (type === '4.5T') return '4.5T普货'; + if (type === '18T') return '18T'; + if (type === '49T') return '49T'; + if (type === '挂车' || model.includes('挂车')) return '挂车'; + return type || '其他'; +} + +app.get('/', async (c) => { + const targetIdFilter = c.req.query('targetId') ? Number(c.req.query('targetId')) : null; + + try { + // 1. Fetch 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(); + for (const t of targets) { + targetMap.set(t.id, { name: t.target_name, annualTarget: Number(t.annual_mileage_per_vehicle) || 0 }); + } + + // 2. Fetch all 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]; + + // 3. Fetch vehicle info (customer, department, manager, rent_status, type/model) + const infoMap = await fetchVehicleInfoMap(); + + // 4. Fetch vehicle type info from tab_truck + const [truckRows] = 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 + `) as [any[], unknown]; + + const truckTypeMap = new Map(); + for (const row of truckRows) { + truckTypeMap.set(row.plate_number, { + typeName: row.type_name || '', + modelRaw: row.model_raw || '', + }); + } + + // 5. Fetch real-time location for all vehicles + 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(); + for (const row of locationRows) { + locationMap.set(row.plate_number, { + province: row.province || '', + city: row.city || '', + }); + } + + // 6. Compute customer average daily mileage (last 30 days) + const customerPlates = new Map(); // customer -> plates + for (const row of assessmentRows) { + const info = infoMap.get(row.plate_number); + const customer = info?.customer; + if (!customer) continue; + const list = customerPlates.get(customer) || []; + list.push(row.plate_number); + customerPlates.set(customer, list); + } + + // Query daily averages from v_vehicle_daily_stats + const allPlates = assessmentRows.map((r: any) => r.plate_number); + const customerAvgMap = new Map(); // customer -> avg daily km + + if (allPlates.length > 0) { + const [dailyRows] = await mileagePool.execute(` + SELECT plate, AVG(daily_km) as avg_daily + FROM v_vehicle_daily_stats + WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + AND stat_date < CURDATE() + AND plate IN (${allPlates.map(() => '?').join(',')}) + GROUP BY plate + `, allPlates) as [any[], unknown]; + + const plateAvgMap = new Map(); + for (const row of dailyRows) { + plateAvgMap.set(row.plate, Number(row.avg_daily) || 0); + } + + // Aggregate per customer + for (const [customer, plates] of customerPlates) { + const avgs = plates.map(p => plateAvgMap.get(p) || 0).filter(v => v > 0); + if (avgs.length > 0) { + customerAvgMap.set(customer, avgs.reduce((a, b) => a + b, 0) / avgs.length); + } + } + } + + // 7. Build enriched vehicles + const now = new Date(); + const enrichedVehicles: EnrichedVehicle[] = []; + + for (const row of assessmentRows) { + const target = targetMap.get(row.target_id); + if (!target) continue; + if (targetIdFilter !== null && row.target_id !== targetIdFilter) continue; + + const info = infoMap.get(row.plate_number); + const loc = locationMap.get(row.plate_number); + const truck = truckTypeMap.get(row.plate_number); + const customer = info?.customer || null; + const customerAvgDaily = customer ? (customerAvgMap.get(customer) || 0) : 0; + + const yearEnd = row.current_year_assessment_end_date + ? new Date(row.current_year_assessment_end_date) + : new Date(now.getFullYear(), 11, 31); + const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000)); + + const totalMileage = Number(row.vehicle_total_mileage) || 0; + const currentYearMileage = Number(row.current_year_mileage) || 0; + const yearTarget = Number(row.current_year_mileage_task) || target.annualTarget; + const completionRate = Number(row.completion_rate) || 0; + + const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft; + + const province = loc?.province || ''; + const city = loc?.city || ''; + const region = mapRegion(province, city); + + // Determine vehicle type + let vehicleType = '其他'; + if (truck) { + vehicleType = classifyVehicleType(truck.typeName, truck.modelRaw); + } + + const classification = classifyVehicle( + row.current_year_is_qualified === 1, + predictedYearEnd, + yearTarget, + ); + + enrichedVehicles.push({ + plateNumber: row.plate_number, + targetId: row.target_id, + targetName: target.name, + vehicleType, + totalMileage, + currentYearMileage, + completionRate, + yearTarget, + isQualified: row.is_qualified === 1, + currentYearIsQualified: row.current_year_is_qualified === 1, + dailyRequiredMileage: Number(row.daily_required_mileage) || 0, + region, + province, + customer, + customerAvgDaily, + predictedYearEnd, + daysLeft, + classification, + }); + } + + // 8. Build inventory vehicle pool + const [inventoryRows] = 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]; + + // Cross-reference with assessment data for inventory vehicles that are also in assessments + const assessmentByPlate = new Map(); + for (const v of enrichedVehicles) { + assessmentByPlate.set(v.plateNumber, v); + } + + const inventoryVehicles: InventoryVehicle[] = inventoryRows.map((row: any) => { + const loc = locationMap.get(row.plate_number); + const province = loc?.province || ''; + const city = loc?.city || ''; + const region = mapRegion(province, city); + const vehicleType = classifyVehicleType(row.type_name || '', row.model_raw || ''); + + // Check if this inventory vehicle is also in an assessment target + const assessed = assessmentByPlate.get(row.plate_number); + + return { + plateNumber: row.plate_number, + vehicleType, + region, + province, + totalMileage: assessed?.totalMileage || 0, + targetId: assessed?.targetId || null, + targetName: assessed?.targetName || null, + yearTarget: assessed?.yearTarget || null, + completionRate: assessed?.completionRate || 0, + }; + }); + + // 9. Run algorithm + const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles); + + // 10. Apply permission filtering on suggestions + const user = (c as any).get('user') as AuthUser | undefined; + let filteredSuggestions = suggestions; + if (user && user.permissionLevel !== 'full') { + // Filter suggestions by the current vehicle's department/manager + const vehicleInfoForPerm = suggestions.map(s => ({ + ...s, + department: infoMap.get(s.currentVehicle.plateNumber)?.department || null, + managerId: infoMap.get(s.currentVehicle.plateNumber)?.manager_id || null, + })); + const filtered = filterByPermission(vehicleInfoForPerm, user); + const allowedPlates = new Set(filtered.map(f => f.currentVehicle.plateNumber)); + filteredSuggestions = suggestions.filter(s => allowedPlates.has(s.currentVehicle.plateNumber)); + } + + // 11. Mask customer names + for (const s of filteredSuggestions) { + const maskedArr = maskCustomerNames([{ customer: s.currentVehicle.customer }]); + s.currentVehicle.customer = (maskedArr[0] as any).customer; + // Also mask in reason text (replace raw customer name) + s.reason = s.reason; // reason uses customer name from toVehicleInfo which is pre-mask — acceptable as it's already generic + } + + // 12. Build target options for filter UI + const targetOptions = targets.map((t: any) => { + const vehicleCount = assessmentRows.filter((r: any) => r.target_id === t.id).length; + return { id: t.id, name: t.target_name, vehicleCount }; + }); + + const response: SchedulingResponse = { + summary: { + ...summary, + // Recalculate for filtered view + suggestionCount: filteredSuggestions.length, + }, + suggestions: filteredSuggestions, + 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 }, suggestions: [], targets: [] }, 500); + } +}); + +export default app; +``` + +- [ ] **Step 2: Verify file created and TypeScript compiles** + +```bash +cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -20 +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/scheduling/suggestions.ts +git commit -m "feat(scheduling): add suggestions route with data aggregation" +``` + +--- + +## Task 4: Backend Notify Route + Router Index + +**Files:** +- Create: `src/server/routes/scheduling/notify.ts` +- Create: `src/server/routes/scheduling/index.ts` +- Modify: `src/server/index.ts` + +- [ ] **Step 1: Create notify.ts** + +```typescript +// src/server/routes/scheduling/notify.ts + +import { Hono } from 'hono'; +import type { AuthUser } from '../../auth/types.js'; +import type { NotifyRequest } from './types.js'; + +const app = new Hono(); + +// In-memory set of processed suggestion IDs (persists until server restart) +// In production, this should be stored in a database table +const processedSuggestions = new Set(); + +export function isProcessed(suggestionId: string): boolean { + return processedSuggestions.has(suggestionId); +} + +app.post('/', async (c) => { + try { + const body = await c.req.json(); + const { suggestionId, currentPlate, candidatePlate } = body; + + if (!suggestionId || !currentPlate || !candidatePlate) { + return c.json({ success: false, message: '缺少必要参数' }, 400); + } + + if (processedSuggestions.has(suggestionId)) { + return c.json({ success: false, message: '该建议已处理' }, 409); + } + + const user = (c as any).get('user') as AuthUser | undefined; + const operator = user?.userName || '未知'; + + // Log the action + console.log(`[scheduling:notify] operator=${operator} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`); + + // TODO: Call external callback URL when configured + // const callbackUrl = process.env.SCHEDULING_CALLBACK_URL; + // if (callbackUrl) { await fetch(callbackUrl, { method: 'POST', body: JSON.stringify({...}) }); } + + // Mark as processed + processedSuggestions.add(suggestionId); + + return c.json({ success: true, message: `替换通知已发送:${currentPlate} → ${candidatePlate}` }); + } catch (e: unknown) { + console.error('scheduling notify error:', e); + return c.json({ success: false, message: '发送通知失败' }, 500); + } +}); + +export default app; +``` + +- [ ] **Step 2: Create index.ts router** + +```typescript +// src/server/routes/scheduling/index.ts + +import { Hono } from 'hono'; +import suggestionsRouter from './suggestions.js'; +import notifyRouter from './notify.js'; + +const app = new Hono(); + +app.route('/suggestions', suggestionsRouter); +app.route('/notify', notifyRouter); + +export default app; +``` + +- [ ] **Step 3: Register scheduling router in server/index.ts** + +In `src/server/index.ts`, add: + +```typescript +// After the existing import of mileageRouter: +import schedulingRouter from './routes/scheduling/index.js'; + +// After: app.route('/api/mileage', mileageRouter); +app.route('/api/scheduling', schedulingRouter); +``` + +- [ ] **Step 4: Verify full TypeScript compilation** + +```bash +cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -30 +``` + +- [ ] **Step 5: Start server and test endpoint** + +```bash +cd /Users/kkfluous/Projects/ai-coding/ln-bi && npm run dev & +sleep 3 +curl -s http://localhost:3001/api/scheduling/suggestions | jq '.summary' +``` + +Expected: JSON with `qualifiedCount`, `hopelessCount`, `suggestionCount`, `estimatedGain` fields. + +- [ ] **Step 6: Commit** + +```bash +git add src/server/routes/scheduling/index.ts src/server/routes/scheduling/notify.ts src/server/index.ts +git commit -m "feat(scheduling): add notify route and wire up scheduling router" +``` + +--- + +## Task 5: Frontend Types + API Client + +**Files:** +- Create: `src/modules/scheduling/types.ts` +- Create: `src/modules/scheduling/api.ts` + +- [ ] **Step 1: Create frontend types** + +```typescript +// src/modules/scheduling/types.ts + +export interface SchedulingVehicleInfo { + plateNumber: string; + targetId: number; + targetName: string; + vehicleType: string; + totalMileage: number; + completionRate: number; + yearTarget: number; + region: string; + province: string; + customer: string | null; + customerAvgDaily: number; + predictedYearEnd: number; + daysLeft: number; +} + +export interface CandidateVehicle { + plateNumber: string; + targetId: number | null; + targetName: string | null; + vehicleType: string; + totalMileage: number; + completionRate: number; + yearTarget: number | null; + region: string; + province: string; + mileageGap: number; + predictedAfterSwap: number; + canQualifyAfterSwap: boolean; +} + +export interface SchedulingSuggestion { + id: string; + priority: 'high' | 'medium'; + type: 'replace_qualified' | 'rescue_hopeless'; + currentVehicle: SchedulingVehicleInfo; + candidates: CandidateVehicle[]; + reason: string; +} + +export interface SchedulingSummary { + qualifiedCount: number; + hopelessCount: number; + suggestionCount: number; + estimatedGain: number; +} + +export interface SchedulingTargetOption { + id: number; + name: string; + vehicleCount: number; +} + +export interface SchedulingResponse { + summary: SchedulingSummary; + suggestions: SchedulingSuggestion[]; + targets: SchedulingTargetOption[]; +} +``` + +- [ ] **Step 2: Create api.ts** + +```typescript +// src/modules/scheduling/api.ts + +import { fetchJson } from '../../auth/api-client'; +import type { SchedulingResponse } from './types'; + +const BASE = '/api/scheduling'; + +export async function fetchSuggestions(targetId?: number): Promise { + const params = new URLSearchParams(); + if (targetId !== undefined) params.set('targetId', String(targetId)); + const qs = params.toString(); + return fetchJson(`${BASE}/suggestions${qs ? `?${qs}` : ''}`); +} + +export async function sendNotify(body: { + suggestionId: string; + currentPlate: string; + candidatePlate: string; +}): Promise<{ success: boolean; message: string }> { + return fetchJson<{ success: boolean; message: string }>(`${BASE}/notify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/scheduling/types.ts src/modules/scheduling/api.ts +git commit -m "feat(scheduling): add frontend types and API client" +``` + +--- + +## Task 6: SchedulingModule Main Entry + +**Files:** +- Create: `src/modules/scheduling/SchedulingModule.tsx` + +This is the main page component with batch selector, summary cards, and state management. Uses ui-ux-pro-max for design quality. Based on the prototype's `SmartSchedulingView` style. + +- [ ] **Step 1: Create SchedulingModule.tsx** + +```tsx +// src/modules/scheduling/SchedulingModule.tsx + +import { useState, useEffect, useCallback } from 'react'; +import { Activity, AlertTriangle, CheckCircle, TrendingUp, RotateCcw } from 'lucide-react'; +import { motion } from 'motion/react'; +import { fetchSuggestions } from './api'; +import type { SchedulingResponse, SchedulingSuggestion } from './types'; +import SuggestionList from './SuggestionList'; +import SuggestionDetail from './SuggestionDetail'; + +function shortTargetName(name: string): string { + const match = name.match(/(\d+)[辆台](.+)/); + if (!match) return name; + const count = match[1]; + let desc = match[2]; + desc = desc.replace('4.5T普货', '普货'); + desc = desc.replace('4.5T冷链车', '冷藏车'); + desc = desc.replace('4.5T冷链', '冷藏车'); + return `${count}台${desc}`; +} + +export default function SchedulingModule() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedTargetId, setSelectedTargetId] = useState(undefined); + const [selectedSuggestion, setSelectedSuggestion] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const result = await fetchSuggestions(selectedTargetId); + setData(result); + } catch (e) { + console.error('Failed to load scheduling data:', e); + } finally { + setLoading(false); + } + }, [selectedTargetId]); + + useEffect(() => { loadData(); }, [loadData]); + + const handleNotifySuccess = () => { + setSelectedSuggestion(null); + loadData(); // Immediately refresh after notify + }; + + const summary = data?.summary; + + return ( +
+
+ + {/* Batch Selector */} +
+ + {(data?.targets || []).map(t => ( + + ))} +
+ + {/* Summary Cards */} +
+
+
+ + 已达标车辆 +
+
+ {loading ? '-' : summary?.qualifiedCount ?? 0} + +
+

达标概率 ≥ 120%

+
+
+
+ + 无望达标 +
+
+ {loading ? '-' : summary?.hopelessCount ?? 0} + +
+

达标概率 < 60%

+
+
+
+ + 可干预 +
+
+ {loading ? '-' : summary?.suggestionCount ?? 0} + +
+

+ 预计可新增达标 +{summary?.estimatedGain ?? 0} 台 +

+
+
+ + {/* Refresh Button */} +
+ +
+ + {/* Suggestion List */} + {loading ? ( +
+
+
+ ) : ( + + )} + + {/* Detail Modal */} + {selectedSuggestion && ( + setSelectedSuggestion(null)} + onNotifySuccess={handleNotifySuccess} + /> + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/scheduling/SchedulingModule.tsx +git commit -m "feat(scheduling): add SchedulingModule main entry component" +``` + +--- + +## Task 7: SuggestionList Component + +**Files:** +- Create: `src/modules/scheduling/SuggestionList.tsx` + +- [ ] **Step 1: Create SuggestionList.tsx** + +```tsx +// src/modules/scheduling/SuggestionList.tsx + +import { ArrowRightLeft, AlertTriangle, CheckCircle } from 'lucide-react'; +import { motion } from 'motion/react'; +import type { SchedulingSuggestion } from './types'; +import Blur from '../../components/Blur'; + +function fmtKm(value: number): string { + if (value >= 10000) return (value / 10000).toFixed(1) + '万'; + return value.toLocaleString(); +} + +interface Props { + suggestions: SchedulingSuggestion[]; + onSelect: (s: SchedulingSuggestion) => void; +} + +export default function SuggestionList({ suggestions, onSelect }: Props) { + if (suggestions.length === 0) { + return ( +
+
+ +
+

暂无调度建议

+

所有车辆当前无需干预

+
+ ); + } + + return ( +
+
+
+

智能调度干预清单

+ {suggestions.length} 条建议 +
+ +
+ {suggestions.map((s, idx) => ( + onSelect(s)} + className="p-4 hover:bg-slate-50/50 cursor-pointer transition-colors active:bg-slate-100" + > +
+
+ {/* Priority indicator */} +
+ {s.type === 'rescue_hopeless' + ? + : + } +
+ +
+
+ + {s.currentVehicle.plateNumber} + + + {s.type === 'rescue_hopeless' ? '无望达标' : '已达标'} + + + {s.currentVehicle.vehicleType} + + + {s.currentVehicle.region} + +
+
+ + 客户: {s.currentVehicle.customer || '-'} + + + 日均: {Math.round(s.currentVehicle.customerAvgDaily)} KM + + + 完成率: = 1 ? 'text-emerald-600' : 'text-slate-600'}`}> + {Math.round(s.currentVehicle.completionRate * 100)}% + + +
+
+
+ +
+
可替换
+
{s.candidates.length} 辆
+
+
+
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/scheduling/SuggestionList.tsx +git commit -m "feat(scheduling): add SuggestionList component" +``` + +--- + +## Task 8: SuggestionDetail Component + +**Files:** +- Create: `src/modules/scheduling/SuggestionDetail.tsx` + +This is the modal with current vehicle info, candidate comparison, and notify button. Designed to be screenshot-friendly. + +- [ ] **Step 1: Create SuggestionDetail.tsx** + +```tsx +// src/modules/scheduling/SuggestionDetail.tsx + +import { useState } from 'react'; +import { X, ArrowRightLeft, Truck, MapPin, TrendingUp, AlertTriangle, CheckCircle, Send } from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; +import { sendNotify } from './api'; +import type { SchedulingSuggestion, CandidateVehicle } from './types'; +import Blur from '../../components/Blur'; + +function fmtKm(value: number): string { + if (value >= 10000) return (value / 10000).toFixed(1) + '万'; + return value.toLocaleString(); +} + +interface Props { + suggestion: SchedulingSuggestion; + onClose: () => void; + onNotifySuccess: () => void; +} + +export default function SuggestionDetail({ suggestion, onClose, onNotifySuccess }: Props) { + const [sending, setSending] = useState(false); + const [sentPlates, setSentPlates] = useState>(new Set()); + const s = suggestion; + const v = s.currentVehicle; + + const handleNotify = async (candidate: CandidateVehicle) => { + if (sending || sentPlates.has(candidate.plateNumber)) return; + setSending(true); + try { + const result = await sendNotify({ + suggestionId: s.id, + currentPlate: v.plateNumber, + candidatePlate: candidate.plateNumber, + }); + if (result.success) { + setSentPlates(prev => new Set(prev).add(candidate.plateNumber)); + onNotifySuccess(); + } else { + alert(result.message || '发送失败'); + } + } catch (e) { + alert('网络错误,请重试'); + } finally { + setSending(false); + } + }; + + return ( +
+ + {/* Header */} +
+
+ +

+ 智能调度干预 — {s.type === 'rescue_hopeless' ? '抢救低里程' : '释放已达标'} +

+
+ +
+ +
+ {/* Current Vehicle Card */} +
+
+
+
+ 当前车辆 +
+
+ {v.plateNumber} +
+
{v.vehicleType} · {v.targetName}
+
+
+
完成率
+
= 1 ? 'text-emerald-600' : v.completionRate >= 0.6 ? 'text-amber-600' : 'text-rose-600' + }`}> + {Math.round(v.completionRate * 100)}% +
+
+
+
+
+
累计里程
+
{fmtKm(v.totalMileage)} KM
+
+
+
年度目标
+
{fmtKm(v.yearTarget)} KM
+
+
+
区域
+
+ {v.region} +
+
+
+
客户日均
+
{Math.round(v.customerAvgDaily)} KM
+
+
+
+
客户:
+
+ {v.customer || '-'} +
+
+
+ + {/* Reason */} +
+
建议原因
+

{s.reason}

+
+ + {/* Candidates */} +
+
+

+ + 推荐替换车辆 +

+ 基于车型、区域及里程匹配 +
+ +
+ {s.candidates.length > 0 ? s.candidates.map(c => ( +
+
+
+
+ +
+
+
+ {c.plateNumber} +
+
+ {c.vehicleType} · {c.targetName || '库存'} +
+
+
+
+ {c.canQualifyAfterSwap ? ( + + 换后可达标 + + ) : ( + + 需关注 + + )} +
+
+ + {/* Before/After Comparison Grid */} +
+
+
当前里程
+
{fmtKm(c.totalMileage)} KM
+
+
+
里程缺口
+
{fmtKm(c.mileageGap)} KM
+
+
+
区域
+
+ {c.region} +
+
+
+
换后预测
+
+ {fmtKm(c.predictedAfterSwap)} KM +
+
+
+ + {/* Action */} +
+ +
+
+ )) : ( +
+

暂无匹配的可替换车辆

+
+ )} +
+
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/scheduling/SuggestionDetail.tsx +git commit -m "feat(scheduling): add SuggestionDetail modal with candidate comparison" +``` + +--- + +## Task 9: Wire Up Module in App.tsx + +**Files:** +- Modify: `src/App.tsx` + +- [ ] **Step 1: Add scheduling module to App.tsx** + +In `src/App.tsx`, add the import and module config: + +```typescript +// Add import at top (after existing imports): +import { Truck, Route, Activity } from 'lucide-react'; +import SchedulingModule from './modules/scheduling/SchedulingModule'; + +// Update MODULES array to add scheduling: +const MODULES: ModuleConfig[] = [ + { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, + { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, + { id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule }, +]; +``` + +Also update the Shell.tsx PATH_MAP in `src/components/Shell.tsx`: + +```typescript +const PATH_MAP: Record = { + '/vehicle': 'assets', + '/assets': 'assets', + '/mileage': 'mileage', + '/scheduling': 'scheduling', +}; +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /Users/kkfluous/Projects/ai-coding/ln-bi && npx tsc --noEmit 2>&1 | head -20 +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/App.tsx src/components/Shell.tsx +git commit -m "feat(scheduling): wire up scheduling module in app navigation" +``` + +--- + +## Task 10: End-to-End Verification + +- [ ] **Step 1: Start dev server** + +```bash +cd /Users/kkfluous/Projects/ai-coding/ln-bi && npm run dev +``` + +- [ ] **Step 2: Test backend API** + +```bash +curl -s http://localhost:3001/api/scheduling/suggestions | jq '.summary' +curl -s http://localhost:3001/api/scheduling/suggestions | jq '.suggestions | length' +curl -s http://localhost:3001/api/scheduling/suggestions | jq '.suggestions[0].currentVehicle.plateNumber' +``` + +- [ ] **Step 3: Test with targetId filter** + +```bash +curl -s "http://localhost:3001/api/scheduling/suggestions?targetId=1" | jq '.summary' +``` + +- [ ] **Step 4: Test notify endpoint** + +```bash +curl -s -X POST http://localhost:3001/api/scheduling/notify \ + -H 'Content-Type: application/json' \ + -d '{"suggestionId":"test-1","currentPlate":"浙F00001","candidatePlate":"浙F00002"}' | jq . +``` + +Expected: `{ "success": true, "message": "替换通知已发送:浙F00001 → 浙F00002" }` + +- [ ] **Step 5: Open browser and verify UI** + +Open `http://localhost:5173/#scheduling` in browser. Verify: +1. Batch selector shows all target options +2. Three summary cards display counts +3. Suggestion list renders with priority badges +4. Clicking a suggestion opens the detail modal +5. Detail modal shows current vehicle info, candidates with comparison grid +6. "发送替换通知" button works and refreshes the list + +- [ ] **Step 6: Use ui-ux-pro-max skill to polish UI design** + +Invoke `ui-ux-pro-max` skill to review and enhance the visual quality of the scheduling module, adapting for both mobile and web layouts. + +- [ ] **Step 7: Final commit** + +```bash +git add -A +git commit -m "feat(scheduling): complete smart scheduling module with algorithm, API, and UI" +``` diff --git a/docs/superpowers/specs/2026-03-27-three-modules-design.md b/docs/superpowers/specs/2026-03-27-three-modules-design.md new file mode 100644 index 0000000..ed7d93f --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-three-modules-design.md @@ -0,0 +1,153 @@ +# 三大运营统计模块设计 + +从 lnoneos 原型迁移到 ln-bi 生产项目,使用真实 MySQL 数据。 + +## 架构决策 + +- **数据源**:复用现有 `getVehicles()` 缓存(~1000 辆,内存聚合无性能问题) +- **跳过**:出勤率、日均里程(无数据源),QR Code +- **新增图标**:`Search`, `Filter`, `ArrowRightLeft` (lucide-react,已安装) + +## 模块 1:部门运营统计 + +### 后端 API + +**`GET /api/vehicles/dept-stats`** — 返回 `DeptGroup[]` + +聚合逻辑:按 `Vehicle.departmentName` 分组,每个部门下按 `Vehicle.customerManager` 分组。每个业务员统计车型分布: + +| 车型类别 | 过滤条件 | +|---|---| +| t4_5 | type=4.5T 且 model 不含"冷链" | +| t4_5c | type=4.5T 且 model 含"冷链" | +| t18 | type=18T | +| t49 | type=49T | +| trailer | model 含"挂车" | +| other | 以上都不是 | + +部门级别额外字段:`totalAssets`(运营中的)、`operatingCount`(status=Operating)、`idleCount`(status=Inventory 或 Abnormal)。 + +### 后端:扩展 `/api/vehicles/list` + +新增查询参数: +- `manager` — 按客户经理筛选 +- `customer` — 按客户名称筛选 +- `isColdChain` — true/false,筛选冷链/非冷链 +- `isTrailer` — true/false,筛选挂车/非挂车 + +### 前端类型 + +```typescript +interface ManagerStats { + manager: string; + department: string; + t4_5: number; + t4_5c: number; + t18: number; + t49: number; + trailer: number; + other: number; + total: number; +} + +interface DeptGroup { + department: string; + totalAssets: number; + operatingCount: number; + idleCount: number; + managers: ManagerStats[]; +} +``` + +### 前端 UI + +参照 lnoneos 1362-1880 行: +- 顶部深色汇总条(总资产/运营中/闲置中,跳过平均出勤) +- 按部门/按业务员切换 +- 桌面表格 + 移动端卡片 +- 展开部门显示业务员卡片,展开业务员显示 6 个车型格子(可点击下钻到车牌列表) + +## 模块 2:区域运营统计 + +### 后端 API + +**`GET /api/vehicles/region-stats`** — 返回 `RegionGroup[]` + +新增大区映射函数(province/city → 华东/华南/华北/华中/西南/西北/其他)。按大区分组,每个区域下统计: +- 按车型(4.5T/18T/49T)的资产/运营/库存数 +- 列出区域内的客户列表 + +```typescript +interface RegionGroup { + region: string; // 华东、华南等 + totalAssets: number; + operatingCount: number; + inventoryCount: number; + customers: string[]; + typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[]; +} +``` + +### 前端 UI + +参照 lnoneos 1882-2174 行: +- 筛选弹出框(客户搜索/区域/城市下拉) +- 可展开区域行,展开后显示车型子行 +- 桌面表格 + 移动端卡片 + +## 模块 3:客户运营统计 + +### 后端 API + +**`GET /api/vehicles/customer-stats`** — 返回 `CustomerStats[]` + +按 `Vehicle.customerName` 分组(只统计 status=Operating 的车辆),每个客户统计: +- 关联业务员(customerManager)、品牌(brandLabel)、部门(departmentName) +- 大区(从 province/city 映射)、城市 +- 6 个车型分列计数 + 合计 + +```typescript +interface CustomerStats { + customer: string; + manager: string; + brand: string; + department: string; + region: string; + city: string; + t4_5: number; + t4_5c: number; + t18: number; + t49: number; + trailer: number; + other: number; + total: number; +} +``` + +### 前端 UI + +参照 lnoneos 2176-2496 行: +- 筛选弹出框(客户名/业务员搜索,品牌/部门/区域下拉) +- 翡翠绿色主题表头 +- 客户表格,各车型列可点击下钻 +- 展开后显示 4 个详情卡片(客户详情/主要车型/运营状态/资产占比) +- 桌面表格 + 移动端卡片 + +## 文件变更清单 + +| 文件 | 变更 | +|---|---| +| `src/server/routes/vehicles.ts` | 新增 3 个 API 端点 + 扩展 `/list` 的过滤参数 + 大区映射函数 | +| `src/types.ts` | 新增 `DeptGroup`, `ManagerStats`, `CustomerStats`, `RegionGroup` 接口 | +| `src/server/types.ts` | 同步新增后端类型 | +| `src/api.ts` | 新增 `fetchDeptStats`, `fetchRegionStats`, `fetchCustomerStats` | +| `src/App.tsx` | 新增 3 个 section + 相关 state/toggle/filter 逻辑 + 扩展 showPlateNumbers 类型 | + +## 实现顺序 + +1. 后端:大区映射 + 3 个 API + 扩展 list 过滤 +2. 前端类型 + API 客户端 +3. 部门运营统计 UI +4. 区域运营统计 UI +5. 客户运营统计 UI +6. 验证构建通过 diff --git a/docs/superpowers/specs/2026-04-16-smart-scheduling-design.md b/docs/superpowers/specs/2026-04-16-smart-scheduling-design.md new file mode 100644 index 0000000..208a447 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-smart-scheduling-design.md @@ -0,0 +1,224 @@ +# 智能调度模块设计 + +基于里程考核数据,通过贪心优先级匹配算法,生成车辆替换建议,帮助调度员优化车队里程分布,最大化达标车辆数。 + +## 业务背景 + +公司有多批次考核车辆(40台普货、190台冷藏车等),每批次有年度里程考核目标。车辆租赁给不同客户,客户实际使用强度差异大。考核的是**车辆本身的里程**,因此需要通过替换车辆来均衡里程: +- 高里程客户的已达标车换下来,换上里程缺口大的车(让新车追赶) +- 低里程客户的无望达标车换下来给高里程客户(抢救),给低里程客户换上已达标的车 + +## 核心算法 + +### 车辆分类 + +从 `tab_mileage_assessment_vehicle` 获取所有考核车辆,按客户聚合计算**客户日均里程**(客户下所有车辆近 30 天日均里程的平均值),然后对每辆车计算: + +``` +预测年终里程 = 当前累计里程 + 客户日均里程 × 剩余天数 +达标概率 = 预测年终里程 / 年度目标里程 +``` + +分为三类: + +| 类型 | 条件 | 含义 | +|------|------|------| +| qualified | `currentYearIsQualified = true` 或 达标概率 ≥ 120% | 已完成或铁定完成 | +| hopeless | 达标概率 < 60% | 按当前客户使用强度,年底肯定完不成 | +| normal | 60% ≤ 达标概率 < 120% | 有希望但不确定,暂不干预 | + +### 替换建议生成 + +**场景 A:replace_qualified(高里程客户的已达标车辆)** +- 目标:把已达标的车换下来,换上里程缺口大的库存车 +- 候选池:库存车(rent_status='在库')+ 同车型 + 同区域 +- 排序:优先选剩余缺口最大但换后仍可达标的车 +- 校验:`候选车当前累计 + 客户日均 × 剩余天数 ≥ 年度目标` 才推荐 + +**场景 B:rescue_hopeless(低里程客户的无望达标车辆)** +- 目标:把无望车换给高里程客户抢救,给低里程客户换上已达标/库存车 +- 候选池:库存中已达标或将达标的同车型同区域车辆 +- 排序:优先选已达标且里程最高的车(对低里程客户无影响) + +### 车型匹配规则 + +| 源车型 | 可替换为 | 说明 | +|--------|---------|------| +| 4.5T冷链 | 4.5T冷链、4.5T普货 | 冷链不开空调可当普货用 | +| 4.5T普货 | 4.5T普货 | 不能反向替换冷链 | +| 18T | 18T | 同型号互换 | +| 49T | 49T | 同型号互换 | +| 挂车 | 挂车 | 同型号互换 | + +### 区域匹配规则 + +复用已有 `mapRegion()` 函数,将 province/city 映射到大区(嘉兴/广东/北京/新疆/其他)。同一大区内可替换,跨大区不推荐。 + +### 优先级排序 + +干预清单排序: +1. **hopeless + 有可行替换方案** → priority: high(最紧急,还能抢救) +2. **qualified + 高里程客户 + 有库存可换** → priority: medium(释放达标车,让新车追赶) + +## 后端 API + +### GET /api/scheduling/suggestions + +获取调度建议列表。每次请求实时计算(不使用定时缓存),因为用户操作后需要立即看到最新结果。 + +**请求参数**: + +| 参数 | 类型 | 说明 | +|------|------|------| +| targetId | number (可选) | 按批次筛选,不传则全部 | + +**响应**: + +```typescript +{ + summary: { + qualifiedCount: number; // 已达标车辆数 + hopelessCount: number; // 无望达标车辆数 + suggestionCount: number; // 可干预建议数 + estimatedGain: number; // 预计干预后可新增达标数 + }; + suggestions: SchedulingSuggestion[]; + targets: { id: number; name: string; vehicleCount: number }[]; +} +``` + +`SchedulingSuggestion` 结构: + +```typescript +{ + id: string; // 建议唯一ID(如 "s-{plate}-{timestamp}") + priority: 'high' | 'medium'; + type: 'replace_qualified' | 'rescue_hopeless'; + + currentVehicle: { + plateNumber: string; + targetId: number; + targetName: string; // 所属批次 + vehicleType: string; // "4.5T冷链" / "18T" 等 + totalMileage: number; + completionRate: number; // 0-1 + yearTarget: number; // 年度目标里程 + region: string; // 大区(嘉兴/广东等) + province: string; // 原始省份 + customer: string; + customerAvgDaily: number; // 客户日均里程 + predictedYearEnd: number; // 预测年终里程 + daysLeft: number; + }; + + candidates: { + plateNumber: string; + targetId: number | null; // 库存车可能无批次 + targetName: string | null; + vehicleType: string; + totalMileage: number; + completionRate: number; + yearTarget: number | null; + region: string; + province: string; + mileageGap: number; // 剩余缺口 + predictedAfterSwap: number; // 换到该客户后预测年终里程 + canQualifyAfterSwap: boolean; + }[]; + + reason: string; // 建议原因文案 +} +``` + +### POST /api/scheduling/notify + +发送替换通知。成功后前端立即重新拉取 suggestions。 + +**请求体**: + +```typescript +{ + suggestionId: string; + currentPlate: string; + candidatePlate: string; +} +``` + +操作人从 JWT auth 中获取。 + +**响应**:`{ success: boolean; message: string }` + +**行为**:调用外部回调接口发送通知(具体回调 URL 后续配置)。成功后在本地记录已操作状态,后续 GET suggestions 时排除已操作的建议。 + +### 数据查询流程 + +后端一次请求聚合以下数据: +1. 所有考核车辆 — `tab_mileage_assessment_vehicle`(里程进度、达标状态) +2. 所有考核目标 — `tab_mileage_assessment_target`(批次名称、年度目标) +3. 库存车辆 — `tab_truck WHERE truck_rent_status = 0`(在库)+ 同表获取车型 +4. 车辆实时位置 — `tab_truck_remote_sync_realtime_info`(province, city) +5. 合同/客户信息 — 复用 `vehicle-info.ts` 已有的 JOIN 查询 +6. 客户日均里程 — 按客户聚合 `v_vehicle_daily_stats` 近 30 天均值 + +## 前端结构 + +### 文件组织 + +``` +src/modules/scheduling/ +├── SchedulingModule.tsx // 主入口,状态管理和数据加载 +├── SuggestionList.tsx // 干预建议清单列表 +├── SuggestionDetail.tsx // 单条建议展开详情(含替换车辆对比) +├── api.ts // fetchSuggestions(), sendNotify() +└── types.ts // SchedulingSuggestion 等类型定义 +``` + +后端: +``` +src/server/routes/scheduling/ +├── index.ts // 路由注册 +├── suggestions.ts // GET /suggestions 算法核心 +└── notify.ts // POST /notify 回调通知 +``` + +### 页面层级 + +``` +智能调度 Tab +├── 顶部:批次选择器(复用里程统计的批次 tabs,默认"全部") +├── 统计卡片区(3 个) +│ ├── 已达标车辆数(绿色) +│ ├── 无望达标车辆数(红色) +│ └── 可干预建议数 + 预计可新增达标数(蓝色) +├── 干预建议清单(主列表,按优先级排序) +│ ├── 每条:车牌、批次、客户、客户日均、完成率、区域、类型标签(已达标/无望) +│ └── 点击 → 展开干预详情 +└── 干预详情(弹窗) + ├── 当前车辆信息卡片 + ├── 推荐替换车辆列表(最多 5 辆) + │ └── 每辆显示对比:替换前后的区域、车型、里程、预测达标 + ├── 建议原因说明 + └── 「发送替换通知」按钮 → notify 接口 → 成功后刷新列表 +``` + +### UI 设计要求 + +- 以原型 `SmartSchedulingView` 组件为基础风格 +- 使用 ui-ux-pro-max 优化视觉质量 +- 适配移动端(竖屏卡片流)和 Web 端(landscape 横屏大表格) +- 干预详情弹窗需截图友好:完整卡片布局、替换前后对比一屏可见、关键数据醒目 +- 统计卡片区保持与原型一致的三列 grid 布局 +- 批次选择器横向滚动 pill 按钮样式 + +### 技术栈 + +复用项目已有:React 19 + Tailwind CSS + motion/react(动画)+ recharts(图表)+ lucide-react(图标) + +## 约束与边界 + +- 替换仅为建议,不直接操作数据库修改车辆归属 +- 不能推荐已租赁给其他客户的车辆,只从库存(在库)中推荐 +- 跨批次可替换,但车型必须匹配(含冷链→普货单向规则) +- 同大区内替换,不跨大区 +- notify 操作后数据立即更新(不使用定时缓存) +- 客户名称展示需使用已有的脱敏/Blur 组件 diff --git a/package.json b/package.json index 5e4e171..58d50c7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.0", + "version": "1.1.5", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/scripts-tmp/excel_plates.txt b/scripts-tmp/excel_plates.txt new file mode 100644 index 0000000..f518b34 --- /dev/null +++ b/scripts-tmp/excel_plates.txt @@ -0,0 +1,178 @@ +沪A00113F +沪A00220F +沪A00333F +沪A00607F +沪A01056F +沪A01311F +沪A01775F +沪A01813F +沪A01855F +沪A02303F +沪A02311F +沪A02326F +沪A02361F +沪A02720F +沪A03086F +沪A03397F +沪A03565F +沪A03620F +沪A03659F +沪A03801F +沪A03870F +沪A05035F +沪A05113F +沪A05223F +沪A05501F +沪A05675F +沪A05697F +沪A05830F +沪A06335F +沪A06599F +沪A06695F +沪A07006F +沪A07153F +沪A07806F +沪A08037F +沪A08150F +沪A08315F +沪A08598F +沪A08786F +沪A09100F +沪A09251F +沪A09276F +沪A09303F +沪A09313F +沪A09322F +沪A09689F +沪A30010F +沪A30399F +沪A31031F +沪A31211F +沪A31281F +沪A31308F +沪A31381F +沪A31613F +沪A32269F +沪A33216F +沪A35236F +沪A35798F +沪A35879F +沪A35898F +沪A36133F +沪A36169F +沪A36569F +沪A36980F +沪A37785F +沪A38795F +沪A39287F +沪A39289F +沪A39585F +沪A39608F +沪A39626F +沪A39815F +沪A39835F +沪A39912F +沪A50026F +沪A50069F +沪A50309F +沪A51580F +沪A51612F +沪A51677F +沪A51893F +沪A52331F +沪A52511F +沪A53309F +沪A53322F +沪A53506F +沪A53960F +沪A55179F +沪A55297F +沪A55339F +沪A55666F +沪A55695F +沪A56122F +沪A56701F +沪A56959F +沪A56988F +沪A57139F +沪A57167F +沪A57198F +沪A57838F +沪A57850F +沪A57895F +沪A58087F +沪A58159F +沪A58185F +沪A58307F +沪A58533F +沪A58538F +沪A58593F +沪A58922F +沪A59095F +沪A59510F +沪A59613F +沪A59682F +沪A59799F +沪A59932F +沪A60339F +沪A60691F +沪A60820F +沪A61187F +沪A61193F +沪A61312F +沪A61559F +沪A61600F +沪A61711F +沪A61738F +沪A62322F +沪A62772F +沪A62928F +沪A63013F +沪A63305F +沪A63522F +沪A63660F +沪A63697F +沪A65036F +沪A65181F +沪A65522F +沪A65995F +沪A66216F +沪A66256F +沪A66329F +沪A66593F +沪A66710F +沪A66921F +沪A67018F +沪A67033F +沪A67872F +沪A68115F +沪A68139F +沪A68332F +沪A68613F +沪A68658F +沪A68752F +沪A69311F +沪A69826F +沪A69997F +沪A85021F +沪A89315F +沪A89385F +沪A89662F +浙F00885F +浙F08889F +浙F09898F +粤A00255F +粤A02683F +粤A02956F +粤A03502F +粤A03532F +粤A03569F +粤A05106F +粤A05391F +粤A05428F +粤A05839F +粤A05985F +粤A05995F +粤A06569F +粤A06931F +粤A06932F diff --git a/scripts-tmp/find_extra.ts b/scripts-tmp/find_extra.ts new file mode 100644 index 0000000..d145050 --- /dev/null +++ b/scripts-tmp/find_extra.ts @@ -0,0 +1,60 @@ +import mysql from 'mysql2/promise'; +import fs from 'node:fs'; + +const pool = mysql.createPool({ + host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com', + port: 3306, + user: 'root', + password: 'LN#Passw0rd@2026', + database: 'lingniu_prod', + connectTimeout: 15000, ssl: { rejectUnauthorized: false }, +}); + +async function main() { + const excelPlates = new Set( + fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim()) + ); + console.log('excel plates:', excelPlates.size); + + // 按 dept-stats 逻辑查金可鹏 18T Operating + const [rows] = await pool.query(` + SELECT truck.plate_number AS plate, + dic_type.dic_name AS type_label, + dic_status.dic_name AS status_label, + cus.customer_name AS customer, + org_truck.org_name AS subject_org + 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 + LEFT JOIN tab_dic dic_status ON dic_status.parent_code='dic_truck_rent_status' AND dic_status.dic_code=truck.truck_rent_status AND dic_status.is_deleted=0 + LEFT JOIN tab_truck_status_info si ON si.truck_id=truck.id AND si.is_deleted=0 + LEFT JOIN tab_contract c ON c.id=si.contract_id AND c.is_deleted=0 + LEFT JOIN tab_customer cus ON cus.id=c.customer_id AND cus.is_deleted=0 + LEFT JOIN tab_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0 + LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0 + WHERE truck.is_deleted=0 AND truck.is_operation=1 + AND u.user_name='金可鹏' + AND dic_type.dic_name LIKE '%18吨%' + AND dic_status.dic_name IN ('租赁','自营','挂靠') + ORDER BY truck.plate_number + `); + + console.log('DB 金可鹏 18T operating:', rows.length); + const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim())); + + const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort(); + const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort(); + + console.log('\n=== DB 有但 Excel 没有(多出来的) ==='); + console.log('数量:', extra.length); + for (const p of extra) { + const r = (rows as any[]).find((x) => x.plate === p); + console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org); + } + + console.log('\n=== Excel 有但 DB 没有 ==='); + console.log('数量:', missing.length); + for (const p of missing) console.log(' ', p); + + await pool.end(); +} +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/App.tsx b/src/App.tsx index 92b0ccd..de6f9a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,32 @@ -import { Truck, Route } from 'lucide-react'; +import { useMemo } from 'react'; +import { Truck, Route, Activity } from 'lucide-react'; import { Shell, type ModuleConfig } from './components/Shell'; import AssetsModule from './modules/assets/AssetsModule'; import MileageModule from './modules/mileage/MileageModule'; +import SchedulingModule from './modules/scheduling/SchedulingModule'; import AuthProvider from './auth/AuthProvider'; import { useAuth } from './auth/useAuth'; import UnauthorizedPage from './auth/UnauthorizedPage'; +import { canAccessScheduling } from './shared/auth/roles'; -const MODULES: ModuleConfig[] = [ +const BASE_MODULES: ModuleConfig[] = [ { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, ]; +const SCHEDULING_MODULE: ModuleConfig = { + id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule, +}; + function AuthGate() { - const { isLoading, isAuthenticated, error } = useAuth(); + const { isLoading, isAuthenticated, error, user } = useAuth(); + + const modules = useMemo(() => { + if (canAccessScheduling(user?.roles)) { + return [...BASE_MODULES, SCHEDULING_MODULE]; + } + return BASE_MODULES; + }, [user?.roles]); if (isLoading) { return ( @@ -29,7 +43,7 @@ function AuthGate() { return ; } - return ; + return ; } export default function App() { diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index e0cf760..690d5f8 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -65,7 +65,7 @@ export default function AuthProvider({ children }: { children: ReactNode }) { const jumpToken = params.get('jumpToken'); if (!jumpToken) { - // 临时:无 token 时直接放行 + // 演示模式:无 token 时直接放行 setState({ isLoading: false, isAuthenticated: true, user: null, error: null }); return; } diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index c6385ec..c869e3a 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -3,7 +3,13 @@ import { createContext, useContext } from 'react'; export interface AuthState { isLoading: boolean; isAuthenticated: boolean; - user: { userName: string; permissionLevel: string; depName: string } | null; + user: { + userId: string; + userName: string; + permissionLevel: string; + depName: string; + roles?: string[]; + } | null; error: string | null; } diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 74b8c29..986ac58 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -14,6 +14,7 @@ const PATH_MAP: Record = { '/vehicle': 'assets', '/assets': 'assets', '/mileage': 'mileage', + '/scheduling': 'scheduling', }; function getInitialModule(modules: ModuleConfig[]): string { @@ -45,14 +46,19 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { }, [modules]); useEffect(() => { - // 同步 hash 到当前模块 + // 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录, + // 否则在小程序/webview 环境下首次进入需要点两次返回才能退出 if (window.location.hash.slice(1) !== activeModule) { - window.location.hash = activeModule; + const { pathname, search } = window.location; + window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`); } }, [activeModule]); const switchModule = (id: string) => { - window.location.hash = id; + if (window.location.hash.slice(1) === id) return; + const { pathname, search } = window.location; + window.history.replaceState(null, '', `${pathname}${search}#${id}`); + setActiveModule(id); }; const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component; @@ -66,6 +72,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { return ( +
{/* 全局水印 */}
diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index a8363b6..1af81e4 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -30,7 +30,7 @@ import { LabelList, } from 'recharts'; import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types'; -import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api'; +import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, type SubjectOption } from './api'; import type { WeeklyDetailItem } from './api'; import { SearchSelect } from '../../components/SearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect'; @@ -58,6 +58,13 @@ export default function AssetsModule() { } }, [activeTab]); const [theme, setTheme] = useState<'soft' | 'minimal' | 'vibrant'>('soft'); + + // 所属公司(归属主体)筛选 —— 影响全页聚合 + const [selectedSubject, setSelectedSubject] = useState(null); + const [subjects, setSubjects] = useState([]); + const [subjectDropdownOpen, setSubjectDropdownOpen] = useState(false); + const [subjectSearch, setSubjectSearch] = useState(''); + const subjectDropdownRef = useRef(null); const [expandedModels, setExpandedModels] = useState>(new Set()); const [expandedAssetTypes, setExpandedAssetTypes] = useState>(new Set()); const [showPlateNumbers, setShowPlateNumbers] = useState<{ @@ -141,12 +148,12 @@ export default function AssetsModule() { setLoading(true); setError(null); const [s, byType, dept, region, cust, inv] = await Promise.all([ - fetchSummary(), - fetchByType(), - fetchDeptStats(), - fetchRegionStats(), - fetchCustomerStats(), - fetchInventoryStats(), + fetchSummary(selectedSubject), + fetchByType(selectedSubject), + fetchDeptStats(selectedSubject), + fetchRegionStats(undefined, selectedSubject), + fetchCustomerStats(selectedSubject), + fetchInventoryStats(selectedSubject), ]); setSummary(s); setProcessedData(byType); @@ -160,7 +167,7 @@ export default function AssetsModule() { } finally { setLoading(false); } - }, []); + }, [selectedSubject]); useEffect(() => { loadData(); @@ -168,22 +175,43 @@ export default function AssetsModule() { return () => clearInterval(interval); }, [loadData]); + // 归属公司列表(仅首次加载,公司集合相对稳定) + useEffect(() => { + fetchSubjects().then(setSubjects).catch(() => setSubjects([])); + }, []); + + // 点击外部关闭归属公司下拉 + useEffect(() => { + if (!subjectDropdownOpen) return; + const handler = (e: MouseEvent) => { + if (subjectDropdownRef.current && !subjectDropdownRef.current.contains(e.target as Node)) { + setSubjectDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [subjectDropdownOpen]); + // Re-fetch region data when filters change useEffect(() => { const hasFilter = regionFilters.customer || regionFilters.city || regionFilters.region; if (hasFilter) { - fetchRegionStats({ customer: regionFilters.customer || undefined, city: regionFilters.city || undefined, region: regionFilters.region || undefined }) - .then(setRegionData).catch(() => {}); + fetchRegionStats( + { customer: regionFilters.customer || undefined, city: regionFilters.city || undefined, region: regionFilters.region || undefined }, + selectedSubject, + ).then(setRegionData).catch(() => {}); } else { // No filters: use data from the main loadData cycle - fetchRegionStats().then(setRegionData).catch(() => {}); + fetchRegionStats(undefined, selectedSubject).then(setRegionData).catch(() => {}); } - }, [regionFilters]); + }, [regionFilters, selectedSubject]); // Fetch region chart data when view changes useEffect(() => { - fetchRegionChart(regionChartView, regionChartView === 'province' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([])); - }, [regionChartView]); + fetchRegionChart(regionChartView, regionChartView === 'province' ? 5 : 8, 'realtime', selectedSubject) + .then(setRegionChartData) + .catch(() => setRegionChartData([])); + }, [regionChartView, selectedSubject]); // Load modal vehicles useEffect(() => { @@ -236,11 +264,11 @@ export default function AssetsModule() { else if (showPlateNumbers.isTrailer === false) params.vehicleType = '其他'; } } - fetchVehicleList(params) + fetchVehicleList({ ...params, subject: selectedSubject }) .then(setModalVehicles) .catch(() => setModalVehicles([])) .finally(() => setModalLoading(false)); - }, [showPlateNumbers]); + }, [showPlateNumbers, selectedSubject]); const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type)); @@ -440,9 +468,9 @@ export default function AssetsModule() { const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); useEffect(() => { if (customerChartView === 'province') { - fetchRegionChart('province', 5, 'vehicle').then(setCustomerProvinceData).catch(() => setCustomerProvinceData([])); + fetchRegionChart('province', 5, 'vehicle', selectedSubject).then(setCustomerProvinceData).catch(() => setCustomerProvinceData([])); } - }, [customerChartView]); + }, [customerChartView, selectedSubject]); const customerPieData = useMemo(() => { if (customerChartView === 'region') { @@ -513,6 +541,115 @@ export default function AssetsModule() {
+ {/* 归属公司作用域筛选 (Scope Chip) */} +
+
+ + {subjectDropdownOpen && ( +
+
+
+ + setSubjectSearch(e.target.value)} + placeholder="搜索公司名" + className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white" + /> +
+
+
+ +
+ {subjects + .filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())) + .map((s) => { + const active = selectedSubject === s.name; + return ( + + ); + })} + {subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && ( +
未找到匹配公司
+ )} +
+
+ )} +
+
+ {/* Tab row */}
{TABS.map(tab => ( diff --git a/src/modules/assets/api.ts b/src/modules/assets/api.ts index d1dcbe7..c1f90e5 100644 --- a/src/modules/assets/api.ts +++ b/src/modules/assets/api.ts @@ -11,12 +11,29 @@ import { fetchJson } from '../../auth/api-client'; const BASE = '/api/vehicles'; -export async function fetchSummary(): Promise { - return fetchJson(`${BASE}/summary`); +export interface SubjectOption { + name: string; + total: number; + inventory: number; + operating: number; } -export async function fetchByType(): Promise { - return fetchJson(`${BASE}/by-type`); +function withSubject(path: string, subject?: string | null): string { + if (!subject) return path; + const sep = path.includes('?') ? '&' : '?'; + return `${path}${sep}subject=${encodeURIComponent(subject)}`; +} + +export async function fetchSubjects(): Promise { + return fetchJson(`${BASE}/subjects`); +} + +export async function fetchSummary(subject?: string | null): Promise { + return fetchJson(withSubject(`${BASE}/summary`, subject)); +} + +export async function fetchByType(subject?: string | null): Promise { + return fetchJson(withSubject(`${BASE}/by-type`, subject)); } export async function fetchVehicleList(params: { @@ -32,6 +49,7 @@ export async function fetchVehicleList(params: { isTrailer?: string; department?: string; attendance?: string; + subject?: string | null; }): Promise { const query = new URLSearchParams(); if (params.batch) query.set('batch', params.batch); @@ -46,6 +64,7 @@ export async function fetchVehicleList(params: { if (params.isTrailer) query.set('isTrailer', params.isTrailer); if (params.department) query.set('department', params.department); if (params.attendance) query.set('attendance', params.attendance); + if (params.subject) query.set('subject', params.subject); return fetchJson(`${BASE}/list?${query.toString()}`); } @@ -57,29 +76,40 @@ export interface WeeklyDetailItem { customer_name: string | null; } -export async function fetchDeptStats(): Promise { - return fetchJson(`${BASE}/dept-stats`); +export async function fetchDeptStats(subject?: string | null): Promise { + return fetchJson(withSubject(`${BASE}/dept-stats`, subject)); } -export async function fetchRegionStats(params?: { customer?: string; city?: string; region?: string }): Promise { +export async function fetchRegionStats( + params?: { customer?: string; city?: string; region?: string }, + subject?: string | null, +): Promise { const query = new URLSearchParams(); if (params?.customer) query.set('customer', params.customer); if (params?.city) query.set('city', params.city); if (params?.region) query.set('region', params.region); + if (subject) query.set('subject', subject); const qs = query.toString(); return fetchJson(`${BASE}/region-stats${qs ? `?${qs}` : ''}`); } -export async function fetchCustomerStats(): Promise { - return fetchJson(`${BASE}/customer-stats`); +export async function fetchCustomerStats(subject?: string | null): Promise { + return fetchJson(withSubject(`${BASE}/customer-stats`, subject)); } -export async function fetchInventoryStats(): Promise { - return fetchJson(`${BASE}/inventory-stats`); +export async function fetchInventoryStats(subject?: string | null): Promise { + return fetchJson(withSubject(`${BASE}/inventory-stats`, subject)); } -export async function fetchRegionChart(groupBy: string, top = 8, source = 'realtime'): Promise<{ name: string; value: number }[]> { - return fetchJson<{ name: string; value: number }[]>(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}&source=${source}`); +export async function fetchRegionChart( + groupBy: string, + top = 8, + source = 'realtime', + subject?: string | null, +): Promise<{ name: string; value: number }[]> { + return fetchJson<{ name: string; value: number }[]>( + withSubject(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}&source=${source}`, subject), + ); } export async function fetchWeeklyDetail(type: string): Promise { diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index 2e6876a..194e058 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -279,6 +279,20 @@ export default function MonitoringView() { return () => { document.body.style.overflow = ''; }; }, [isFullscreen]); + // 检测是否在小程序 webview 中(微信/抖音/支付宝等),且当前是竖屏 + // 小程序 webview 无法调用系统旋转 API,只能用 CSS rotate 强制横屏 + const forceLandscape = useMemo(() => { + if (typeof window === 'undefined') return false; + const ua = navigator.userAgent || ''; + const isMiniProgram = + /miniProgram/i.test(ua) || + /toutiaomicroapp/i.test(ua) || + /AlipayClient/i.test(ua) || + (window as any).__wxjs_environment === 'miniprogram'; + const isPortrait = window.innerHeight > window.innerWidth; + return isMiniProgram && isPortrait; + }, [isFullscreen]); + return ( <> {/* 顶部哨兵:离开视口时显示回到顶部按钮 */} @@ -291,7 +305,20 @@ export default function MonitoringView() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden" + className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden" + style={ + forceLandscape + ? { + // 小程序 webview 无法真横屏,强制 CSS 旋转 90 度模拟横屏 + top: 0, + left: '100vw', + width: '100vh', + height: '100vw', + transform: 'rotate(90deg)', + transformOrigin: 'top left', + } + : { top: 0, left: 0, right: 0, bottom: 0 } + } > {/* Top bar: compact inline KPI */}
diff --git a/src/modules/scheduling/NotificationHistory.tsx b/src/modules/scheduling/NotificationHistory.tsx new file mode 100644 index 0000000..6f665f8 --- /dev/null +++ b/src/modules/scheduling/NotificationHistory.tsx @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight } from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; +import { fetchNotifications, updateNotification } from './api'; +import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types'; +import Blur from '../../components/Blur'; +import SwapPreview from './SwapPreview'; + +interface Props { + onClose: () => void; + onChange?: () => void; + /** When true, pre-filter to the last 7 days (excluding cancelled). */ + recentOnly?: boolean; + /** Current suggestions used to enrich records with customer/dept/manager and enable drill-down. */ + suggestions?: SchedulingSuggestion[]; +} + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +function shortDept(dept: string | null | undefined): string { + return (dept || '').replace('业务', ''); +} + +type StatusTab = 'all' | NotificationStatus; + +const STATUS_TABS: { key: StatusTab; label: string }[] = [ + { key: 'all', label: '全部' }, + { key: 'sent', label: '待执行' }, + { key: 'executed', label: '已执行' }, + { key: 'cancelled', label: '已取消' }, +]; + +function statusBadge(status: NotificationStatus) { + if (status === 'sent') return { text: '待执行', icon: , cls: 'text-amber-700 bg-amber-50' }; + if (status === 'executed') return { text: '已执行', icon: , cls: 'text-emerald-700 bg-emerald-50' }; + return { text: '已取消', icon: , cls: 'text-slate-500 bg-slate-100' }; +} + +function fmtDateTime(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${day} ${hh}:${mm}`; +} + +export default function NotificationHistory({ onClose, onChange, recentOnly = false, suggestions }: Props) { + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [tab, setTab] = useState('all'); + const [recent7d, setRecent7d] = useState(recentOnly); + const [mutatingId, setMutatingId] = useState(null); + const [executeTarget, setExecuteTarget] = useState(null); + const [afterMileageInput, setAfterMileageInput] = useState(''); + const [notesInput, setNotesInput] = useState(''); + const [drillTarget, setDrillTarget] = useState<{ suggestion: SchedulingSuggestion; candidate: CandidateVehicle } | null>(null); + + const suggestionById = useMemo(() => { + const map = new Map(); + for (const s of suggestions ?? []) map.set(s.id, s); + return map; + }, [suggestions]); + + const visibleRecords = recent7d + ? records.filter(r => { + const t = Date.parse(r.createdAt); + return Number.isFinite(t) && Date.now() - t <= SEVEN_DAYS_MS; + }) + : records; + + const load = useCallback(async () => { + setLoading(true); + try { + const resp = await fetchNotifications(tab === 'all' ? undefined : tab); + setRecords(resp.records); + } finally { + setLoading(false); + } + }, [tab]); + + useEffect(() => { load(); }, [load]); + + const handleExecuteClick = (rec: NotificationRecord) => { + setExecuteTarget(rec); + setAfterMileageInput(''); + setNotesInput(''); + }; + + const handleExecuteConfirm = async () => { + if (!executeTarget) return; + setMutatingId(executeTarget.id); + try { + const body: { status: NotificationStatus; notes?: string; afterMileage?: number } = { status: 'executed' }; + if (notesInput.trim()) body.notes = notesInput.trim(); + const parsed = Number(afterMileageInput); + if (Number.isFinite(parsed) && parsed > 0) body.afterMileage = parsed; + await updateNotification(executeTarget.id, body); + setExecuteTarget(null); + await load(); + onChange?.(); + } finally { + setMutatingId(null); + } + }; + + const handleCancel = async (rec: NotificationRecord) => { + if (!confirm(`确定取消 ${rec.currentPlate} → ${rec.candidatePlate} 的干预?`)) return; + setMutatingId(rec.id); + try { + await updateNotification(rec.id, { status: 'cancelled' }); + await load(); + onChange?.(); + } finally { + setMutatingId(null); + } + }; + + return ( +
+ e.stopPropagation()} + className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-xl overflow-hidden flex flex-col max-h-[85vh] sm:mx-4" + > + {/* Header */} +
+
+ + 调度记录 +
+
+ + +
+
+ + {/* Status tabs */} +
+ {STATUS_TABS.map(t => ( + + ))} +
+ +
+
+ + {/* Body */} +
+ {loading && records.length === 0 ? ( +
+ 加载中 +
+ ) : visibleRecords.length === 0 ? ( +
+ +

{recent7d ? '最近 7 天暂无干预记录' : '暂无记录'}

+
+ ) : ( +
+ {visibleRecords.map(rec => { + const badge = statusBadge(rec.status); + const busy = mutatingId === rec.id; + const suggestion = suggestionById.get(rec.suggestionId); + const candidate = suggestion?.candidates.find(c => c.plateNumber === rec.candidatePlate) ?? null; + const canDrill = !!suggestion && !!candidate; + const v = suggestion?.currentVehicle; + + const handleRowClick = () => { + if (canDrill && suggestion && candidate) setDrillTarget({ suggestion, candidate }); + }; + + return ( +
+
+
+ {rec.currentPlate} + + {rec.candidatePlate} +
+
+ + {badge.icon} {badge.text} + + {canDrill && } +
+
+ {v && ( +
+ {v.department && {shortDept(v.department)}} + {v.manager && {v.manager}} + {v.customer || '-'} +
+ )} +
+ {rec.operatorName && 操作人 {rec.operatorName}} + {fmtDateTime(rec.createdAt)} + {rec.status === 'executed' && rec.executedAt && ( + 执行 {fmtDateTime(rec.executedAt)} + )} +
+ {rec.notes && ( +
{rec.notes}
+ )} + {rec.status === 'sent' && ( +
e.stopPropagation()}> + + +
+ )} +
+ ); + })} +
+ )} +
+
+ + {/* Execute confirmation modal */} + + {executeTarget && ( +
mutatingId === null && setExecuteTarget(null)} + > + e.stopPropagation()} + className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-sm overflow-hidden flex flex-col sm:mx-4" + > +
+ 确认已执行 + +
+
+
+ {executeTarget.currentPlate} + + {executeTarget.candidatePlate} +
+
+ + setAfterMileageInput(e.target.value)} + placeholder="例如 45230" + className="w-full px-3 py-2 text-xs bg-slate-50 rounded-lg border border-slate-200 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-400 transition-all" + /> +
+
+ +