import XLSX from 'xlsx'; import type { WorkBook } from 'xlsx'; import type { RowDataPacket } from 'mysql2'; import pool from '../src/server/db.js'; import type { MileageReportBreakdown, MileageReportGroup, MileageReportVehicle, } from '../src/shared/mileage/daily-report.js'; import { buildDailyMileageReport, mileageBand, roundMileage, } from '../src/server/routes/mileage/daily-report-model.js'; import { saveDailyReportSnapshot } from '../src/server/routes/mileage/daily-report-store.js'; interface TargetRow extends RowDataPacket { id: number; target_name: string } interface ImportSpec { date: string; file: string; } const SHEET_SPECS = [ { sheet: '40台普货', targetMatch: '交投40', summaryRow: 2, displayName: '40台普货' }, { sheet: '190台冷藏车', targetMatch: '交投190', summaryRow: 3, displayName: '190台冷链' }, { sheet: '136台冷藏车', targetMatch: '羚牛136', summaryRow: 4, displayName: '136台冷链' }, { sheet: '100台双飞翼', targetMatch: '羚牛100', summaryRow: 5, displayName: '100台18T双飞翼' }, { sheet: '50台普货', targetMatch: '恒运', summaryRow: 6, displayName: '26台恒运普货' }, ] as const; const INVENTORY_REGIONS: Record>> = { '2026-03-30': { '40台普货': { 广东: 2 }, '190台冷链': { 嘉兴: 2, 广东: 38 }, '136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 31 }, '100台18T双飞翼': { 嘉兴: 9, 广东: 29 }, '26台恒运普货': { 广东: 11, 嘉兴: 13 }, }, '2026-03-31': { '40台普货': { 广东: 2 }, '190台冷链': { 嘉兴: 2, 广东: 36 }, '136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 32 }, '100台18T双飞翼': { 嘉兴: 8, 广东: 29 }, '26台恒运普货': { 广东: 11, 嘉兴: 12 }, }, }; function parseArgs(values: string[]): ImportSpec[] { return values.map(value => { const split = value.indexOf(':'); if (split < 10) throw new Error(`参数格式应为 YYYY-MM-DD:/path/file.xlsx,收到 ${value}`); const date = value.slice(0, split); const file = value.slice(split + 1); if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !file) throw new Error(`参数不合法:${value}`); return { date, file }; }); } function valueNumber(value: unknown): number { return Number(value) || 0; } function sheetRows(workbook: WorkBook, name: string): unknown[][] { const sheet = workbook.Sheets[name]; if (!sheet) throw new Error(`工作表不存在:${name}`); return XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: null }); } function trendDate(reportDate: string, label: unknown): string { const match = /^(\d{1,2})\.(\d{1,2})日/.exec(String(label || '')); if (!match) throw new Error(`无法识别趋势日期:${String(label)}`); let year = Number(reportDate.slice(0, 4)); const candidate = `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`; if (candidate > reportDate) year -= 1; return `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`; } function breakdown(rows: MileageReportVehicle[], key: 'department'): MileageReportBreakdown[] { const grouped = new Map(); for (const row of rows) { const name = row[key] || '未标注'; const items = grouped.get(name) || []; items.push(row); grouped.set(name, items); } return Array.from(grouped.entries()).map(([name, items]) => ({ name, vehicleCount: items.length, totalMileage: roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0)), averageMileage: items.length > 0 ? roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0) / items.length) : 0, })).sort((a, b) => b.vehicleCount - a.vehicleCount); } function inventoryBreakdown(date: string, groupName: string): MileageReportBreakdown[] { return Object.entries(INVENTORY_REGIONS[date]?.[groupName] || {}).map(([name, count]) => ({ name, vehicleCount: count, totalMileage: 0, averageMileage: 0, })); } function generatedAt(date: string): string { const next = new Date(`${date}T00:00:00Z`); next.setUTCDate(next.getUTCDate() + 1); return `${next.toISOString().slice(0, 10)}T09:30:00+08:00`; } async function importWorkbook(spec: ImportSpec, targets: TargetRow[]): Promise { const workbook = XLSX.readFile(spec.file, { cellDates: true }); const summary = sheetRows(workbook, '汇总'); const groups: MileageReportGroup[] = SHEET_SPECS.map(sheetSpec => { const rows = sheetRows(workbook, sheetSpec.sheet); const headerIndex = rows.findIndex(row => row[0] === '序号'); if (headerIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到明细表头`); const header = rows[headerIndex]; const currentDayIndex = header.findIndex(value => String(value || '').includes(`${Number(spec.date.slice(5, 7))}.${Number(spec.date.slice(8, 10))}日行驶里程`)); if (currentDayIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到 ${spec.date} 日里程列`); const trendStartIndex = currentDayIndex - 6; const trendDates = header.slice(trendStartIndex, currentDayIndex + 1).map(label => trendDate(spec.date, label)); const detailRows = rows.slice(headerIndex + 1).filter(row => typeof row[0] === 'number'); const vehicles: MileageReportVehicle[] = detailRows.map(row => { const rawStatus = String(row[1] || '在库'); const dailyMileage = roundMileage(valueNumber(row[currentDayIndex])); const status = rawStatus === '租赁' || rawStatus === '自营' ? rawStatus : '在库'; return { plate: String(row[2] || ''), status, customer: row[3] ? String(row[3]) : null, department: status === '在库' || !row[4] ? null : String(row[4]), inventoryRegion: null, dailyMileage, cumulativeMileage: roundMileage(valueNumber(row[13])), completionRate: Math.round(valueNumber(row[17]) * 10000) / 100, mileageBand: mileageBand(status, dailyMileage), trend: trendDates.map((date, index) => ({ date, totalMileage: roundMileage(valueNumber(row[trendStartIndex + index])), })), }; }); const operating = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY'); const inventory = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY'); const summaryRow = summary[sheetSpec.summaryRow]; const target = targets.find(item => item.target_name.includes(sheetSpec.targetMatch)); if (!target) throw new Error(`未找到考核目标:${sheetSpec.targetMatch}`); const periods = String(summaryRow[5] || '').split(/\n+|\s{2,}/).map(value => value.trim()).filter(Boolean); const trend = trendDates.map((date, index) => ({ date, totalMileage: roundMileage(detailRows.reduce((total, row) => total + valueNumber(row[trendStartIndex + index]), 0)), })); const dailyMileage = roundMileage(vehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)); const yesterdayMileage = trend[trend.length - 2]?.totalMileage || 0; const currentYearTarget = roundMileage(valueNumber(summaryRow[10])); const currentYearCompleted = roundMileage(valueNumber(summaryRow[11])); return { targetId: target.id, targetName: target.target_name, displayName: sheetSpec.displayName, vehicleCount: vehicles.length, operatingCount: operating.length, inventoryCount: inventory.length, dailyMileage, operatingMileage: roundMileage(operating.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)), inventoryMileage: roundMileage(inventory.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)), yesterdayMileage, dayOverDayDelta: 0, dayOverDayRate: null, lowMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'LOW').length, highMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'HIGH').length, cumulativeMileage: roundMileage(valueNumber(summaryRow[3])), totalMileageTarget: roundMileage(valueNumber(summaryRow[2])), completionRate: Math.round(valueNumber(summaryRow[4]) * 10000) / 100, qualifiedCount: valueNumber(summaryRow[7]), halfQualifiedCount: valueNumber(summaryRow[8]), currentYearTarget, currentYearCompleted, remainingMileage: roundMileage(valueNumber(summaryRow[12])), dailyRequiredMileage: roundMileage(valueNumber(summaryRow[14])), assessmentPeriods: periods, trend, inventoryRegions: inventoryBreakdown(spec.date, sheetSpec.displayName), departments: breakdown(operating, 'department'), vehicles: vehicles.sort((a, b) => b.dailyMileage - a.dailyMileage), }; }); const qualityNotes = [ { level: 'info' as const, message: '历史日报由已审核人工台账导入,车辆规模与当日台账保持一致。' }, ]; if (spec.date === '2026-03-30') { qualityNotes.push({ level: 'info', message: '136台冷链库存按车辆明细修正为45台;100km车辆按低里程口径统计。' }); } qualityNotes.push({ level: 'info', message: '190台冷链累计里程采用190辆明细求和及汇总表值,忽略明细页失效的手工合计单元格。' }); const report = buildDailyMileageReport({ reportDate: spec.date, status: 'ARCHIVED', source: 'XLSX_IMPORT', generatedAt: generatedAt(spec.date), sourceUpdatedAt: null, groups, qualityNotes, }); const inserted = await saveDailyReportSnapshot(report, 'XLSX_IMPORT', { replace }); console.log(`${spec.date}: ${inserted ? (replace ? 'replaced' : 'imported') : 'already exists'}; ${report.totals.vehicleCount} vehicles; ${report.totals.dailyMileage} km`); } const args = process.argv.slice(2); const replace = args.includes('--replace'); const specs = parseArgs(args.filter(value => value !== '--replace')); if (specs.length === 0) throw new Error('请提供至少一个 YYYY-MM-DD:/path/file.xlsx 参数'); const [targets] = await pool.query( 'SELECT id, target_name FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id', ); try { for (const spec of specs) await importWorkbook(spec, targets); } finally { await pool.end(); }