224 lines
10 KiB
TypeScript
224 lines
10 KiB
TypeScript
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<string, Record<string, Record<string, number>>> = {
|
|
'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<unknown[]>(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<string, MileageReportVehicle[]>();
|
|
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<void> {
|
|
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<TargetRow[]>(
|
|
'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();
|
|
}
|