feat: 羚牛 BI 报表服务初始版本
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
- Hono + TypeScript 后端,连接 MySQL 数据库 - React + Vite + Tailwind 前端 - 车辆资产实时汇总(按车型/品牌型号分组) - 本周交车/还车/替换统计(关联业务单据) - 车牌号详情弹窗 - Dockerfile + Woodpecker CI 流水线 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
src/server/db.ts
Normal file
17
src/server/db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
export default pool;
|
||||
26
src/server/index.ts
Normal file
26
src/server/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import dotenv from 'dotenv';
|
||||
import vehiclesRouter from './routes/vehicles.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use('/api/*', cors());
|
||||
app.route('/api/vehicles', vehiclesRouter);
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||
|
||||
// Serve static files in production
|
||||
app.use('/*', serveStatic({ root: './dist' }));
|
||||
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
|
||||
|
||||
const port = Number(process.env.SERVER_PORT) || 3001;
|
||||
|
||||
console.log(`Server starting on port ${port}...`);
|
||||
serve({ fetch: app.fetch, port }, () => {
|
||||
console.log(`Server running at http://localhost:${port}`);
|
||||
});
|
||||
613
src/server/routes/vehicles.ts
Normal file
613
src/server/routes/vehicles.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../db.js';
|
||||
import type {
|
||||
VehicleRow,
|
||||
Vehicle,
|
||||
SummaryData,
|
||||
TypeSummary,
|
||||
ModelSummary,
|
||||
BatchSummary,
|
||||
BatchGroup,
|
||||
InventoryTypeSummary,
|
||||
} from '../types.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const MAIN_SQL = `SELECT
|
||||
truck.id AS id,
|
||||
truck.plate_number AS 车牌号,
|
||||
truck.vin AS vin,
|
||||
truck.brand AS 车辆品牌,
|
||||
truck.model AS 车辆型号,
|
||||
truck.color AS 车辆颜色,
|
||||
truck.rent_from_company AS 租赁公司,
|
||||
dic_ascription_status.dic_name AS 车辆归属状态Label,
|
||||
dic_type.dic_name AS 车辆型号Label,
|
||||
truck.stock_area AS 库存区域,
|
||||
truck.truck_rent_status AS 车辆租赁状态,
|
||||
dic_status.dic_name AS 车辆租赁状态Label,
|
||||
truck.is_operation AS 是否营运,
|
||||
info.province AS 省,
|
||||
info.city AS 市,
|
||||
info.lat AS 纬度,
|
||||
info.lng AS 经度,
|
||||
dic_brand.dic_name AS 车辆品牌Label,
|
||||
si.contract_id AS 合同ID,
|
||||
COALESCE(c.contract_no, si.contract_no) AS 合同编码,
|
||||
cus.customer_name AS 客户名称,
|
||||
org.org_name AS 合同归属公司,
|
||||
dep.dep_name AS 合同归属部门,
|
||||
org_truck.org_name AS 主体,
|
||||
c.project_name AS 项目名称,
|
||||
u.user_name AS 客户经理
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_truck_remote_sync_realtime_info info
|
||||
ON info.id = truck.id
|
||||
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_dic dic_brand
|
||||
ON dic_brand.parent_code = 'dic_vehicle_brand'
|
||||
AND dic_brand.dic_code = truck.brand
|
||||
AND dic_brand.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
|
||||
ON org.id = c.org_id
|
||||
AND org.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_dic dic_ascription_status
|
||||
ON dic_ascription_status.parent_code = 'dic_truck_ascription_status'
|
||||
AND dic_ascription_status.dic_code = truck.ascription_status
|
||||
AND dic_ascription_status.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
|
||||
WHERE truck.is_deleted = 0
|
||||
AND truck.is_operation = 1`;
|
||||
|
||||
// Region mapping: province/city -> display region
|
||||
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
|
||||
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
|
||||
|
||||
function mapRegion(province: string | null, city: string | null): string {
|
||||
if (!province && !city) return '其他';
|
||||
const loc = (city || province || '').trim();
|
||||
if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴';
|
||||
if (loc.includes('广东') || loc.includes('广州') || loc.includes('深圳') || loc.includes('佛山') || loc.includes('东莞')) return '广东';
|
||||
if (loc.includes('北京')) return '北京';
|
||||
if (loc.includes('新疆') || loc.includes('乌鲁木齐')) return '新疆';
|
||||
// Also check province
|
||||
const prov = (province || '').trim();
|
||||
if (prov.includes('浙江') || prov.includes('上海') || prov.includes('江苏')) return '嘉兴';
|
||||
if (prov.includes('广东')) return '广东';
|
||||
if (prov.includes('北京')) return '北京';
|
||||
if (prov.includes('新疆')) return '新疆';
|
||||
return '其他';
|
||||
}
|
||||
|
||||
function mapInventoryRegion(region: string): string {
|
||||
if (region === '嘉兴') return '江浙沪';
|
||||
if (region === '广东') return '广东';
|
||||
if (region === '新疆') return '新疆';
|
||||
return '其它';
|
||||
}
|
||||
|
||||
// Map rental status to frontend status
|
||||
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
|
||||
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Abnormal' {
|
||||
if (!rentStatus) return 'Inventory';
|
||||
const s = rentStatus.trim();
|
||||
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
|
||||
if (s === '在库' || s === '待交车') return 'Inventory';
|
||||
if (s === '异动') return 'Abnormal';
|
||||
return 'Inventory';
|
||||
}
|
||||
|
||||
// Map ownership status
|
||||
// Actual DB values: 自有(0), 外租(1), 挂靠(2)
|
||||
function mapOwnership(ascriptionLabel: string | null): string {
|
||||
if (!ascriptionLabel) return 'Self';
|
||||
const s = ascriptionLabel.trim();
|
||||
if (s === '自有') return 'Self';
|
||||
if (s === '外租') return 'Leased';
|
||||
if (s === '挂靠') return 'Hanging';
|
||||
return 'Self';
|
||||
}
|
||||
|
||||
// Derive vehicle type category from model label
|
||||
// Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头,
|
||||
// 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车
|
||||
function deriveType(modelLabel: string | null, brandLabel: string | null): string {
|
||||
const label = (modelLabel || '').trim();
|
||||
if (label.includes('4.5吨')) return '4.5T';
|
||||
if (label.includes('18吨')) return '18T';
|
||||
if (label.includes('49吨')) return '49T';
|
||||
if (label.includes('35吨')) return '35T';
|
||||
if (label.includes('叉车')) return '叉车';
|
||||
if (label.includes('半挂车')) return '挂车';
|
||||
return '其他车型';
|
||||
}
|
||||
|
||||
// Tag → alias mapping with sort order
|
||||
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
|
||||
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
|
||||
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
|
||||
// 4.5T 普货
|
||||
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
|
||||
'现代-4.5吨货车-白': { alias: '现代4.5T普货(恒运)', order: 102 },
|
||||
// 4.5T 冷链
|
||||
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
|
||||
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
|
||||
'跃进-4.5吨冷链车-白/绿/灰': { alias: '跃进4.5T冷链', order: 203 },
|
||||
// 18T
|
||||
'飞驰-18吨厢式货车-红': { alias: '飞驰18T(红车)', order: 301 },
|
||||
'飞驰-18吨厢式货车-白/绿': { alias: '飞驰18T(白车)', order: 302 },
|
||||
'楚风-18吨厢式货车-白': { alias: '楚风18T厢货', order: 303 },
|
||||
'苏龙-18吨双飞翼货车-白': { alias: '苏龙18T飞翼', order: 304 },
|
||||
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
|
||||
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼(安吉)', order: 305 },
|
||||
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼(羚牛)', order: 306 },
|
||||
// 49T
|
||||
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
|
||||
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
|
||||
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
|
||||
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 }, // merge with above
|
||||
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
|
||||
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
|
||||
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风(海珀特)', order: 406 },
|
||||
// 其他
|
||||
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
|
||||
'其他-氢能叉车-蓝白绿': { alias: '氢能叉车', order: 502 },
|
||||
'通华-重型集装箱半挂车-红色浙江锦昌仓储有限公司': { alias: '挂车', order: 503 },
|
||||
'通华-重型集装箱半挂车-红色嘉兴市鼎义物流有限公司': { alias: '挂车', order: 503 },
|
||||
'通华-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
|
||||
'大通-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
|
||||
'明威-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
|
||||
'明威-重型集装箱半挂车-红': { alias: '挂车', order: 503 },
|
||||
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
|
||||
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
|
||||
'东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 },
|
||||
'腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 },
|
||||
'腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
|
||||
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
|
||||
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
|
||||
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
|
||||
};
|
||||
|
||||
function deriveModelTag(
|
||||
brandLabel: string | null,
|
||||
modelLabel: string | null,
|
||||
color: string | null,
|
||||
ownershipLabel: string | null,
|
||||
rentCompany: string | null,
|
||||
): string {
|
||||
const brand = (brandLabel || '').trim();
|
||||
const model = (modelLabel || '').trim();
|
||||
const c = (color || '').trim();
|
||||
const isRented = ownershipLabel?.trim() === '外租';
|
||||
const company = isRented ? (rentCompany || '').trim() : '';
|
||||
|
||||
if (!brand && !model) return '未知车型';
|
||||
const tag = `${brand}-${model}-${c}${company}`;
|
||||
const mapped = MODEL_ALIAS_MAP[tag];
|
||||
return mapped ? mapped.alias : tag;
|
||||
}
|
||||
|
||||
function getModelOrder(model: string): number {
|
||||
// Find the order from alias mapping
|
||||
for (const entry of Object.values(MODEL_ALIAS_MAP)) {
|
||||
if (entry.alias === model) return entry.order;
|
||||
}
|
||||
return 999;
|
||||
}
|
||||
|
||||
function transformRow(row: VehicleRow): Vehicle {
|
||||
const region = mapRegion(row.省, row.市);
|
||||
return {
|
||||
id: row.id,
|
||||
plateNumber: row.车牌号 || '',
|
||||
vin: row.vin || '',
|
||||
type: deriveType(row.车辆型号Label, row.车辆品牌Label),
|
||||
model: deriveModelTag(row.车辆品牌Label, row.车辆型号Label, row.车辆颜色, row.车辆归属状态Label, row.租赁公司),
|
||||
color: row.车辆颜色 || '',
|
||||
location: region,
|
||||
region,
|
||||
status: mapStatus(row.车辆租赁状态Label),
|
||||
ownership: mapOwnership(row.车辆归属状态Label),
|
||||
rentCompany: row.租赁公司 || '',
|
||||
contractNo: row.合同编码,
|
||||
customerName: row.客户名称,
|
||||
orgName: row.合同归属公司,
|
||||
departmentName: row.合同归属部门,
|
||||
subjectOrg: row.主体,
|
||||
projectName: row.项目名称,
|
||||
customerManager: row.客户经理,
|
||||
brandLabel: row.车辆品牌Label,
|
||||
};
|
||||
}
|
||||
|
||||
// Cache for vehicles data (refresh every 5 minutes)
|
||||
let cachedVehicles: Vehicle[] = [];
|
||||
let lastFetchTime = 0;
|
||||
const CACHE_TTL = 60 * 1000;
|
||||
|
||||
async function getVehicles(): Promise<Vehicle[]> {
|
||||
const now = Date.now();
|
||||
if (cachedVehicles.length > 0 && now - lastFetchTime < CACHE_TTL) {
|
||||
return cachedVehicles;
|
||||
}
|
||||
const [rows] = await pool.query<any[]>(MAIN_SQL);
|
||||
cachedVehicles = (rows as VehicleRow[]).map(transformRow);
|
||||
lastFetchTime = now;
|
||||
return cachedVehicles;
|
||||
}
|
||||
|
||||
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
|
||||
return regions.reduce((acc, reg) => {
|
||||
acc[reg] = vehicles.filter((v) => v.location === reg).length;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
function getStats(list: Vehicle[]) {
|
||||
return {
|
||||
total: list.length,
|
||||
inventory: list.filter((v) => v.status === 'Inventory').length,
|
||||
inventoryRegions: getRegionCounts(
|
||||
list.filter((v) => v.status === 'Inventory'),
|
||||
REGIONS,
|
||||
),
|
||||
pending: 0,
|
||||
operating: list.filter((v) => v.status === 'Operating').length,
|
||||
weeklyDelivered: 0,
|
||||
weeklyReturned: 0,
|
||||
weeklyReplaced: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Week range: last Saturday 00:00 to this Friday 23:59
|
||||
// MySQL: WEEKDAY() returns 0=Monday..6=Sunday, Saturday=5
|
||||
// "上周六-本周五": offset from today to last Saturday
|
||||
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
|
||||
const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
|
||||
|
||||
interface WeeklyStats {
|
||||
pendingDelivery: number;
|
||||
weeklyNew: number;
|
||||
weeklyRemoved: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
}
|
||||
|
||||
// 交车单 SQL
|
||||
const DELIVERED_SQL = `SELECT
|
||||
take.id, DATE(take.handover_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_take take
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||
LEFT JOIN tab_dic dic_contract_type
|
||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||
AND dic_contract_type.dic_code = contract.contract_type
|
||||
AND dic_contract_type.is_deleted = 0
|
||||
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
||||
AND task.task_type = 1 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
|
||||
|
||||
// 还车单 SQL
|
||||
const RETURNED_SQL = `SELECT
|
||||
r.id, DATE(r.return_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_return r
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||
LEFT JOIN tab_dic dic_contract_type
|
||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||
AND dic_contract_type.dic_code = contract.contract_type
|
||||
AND dic_contract_type.is_deleted = 0
|
||||
WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
|
||||
|
||||
// 替换车单 SQL
|
||||
const REPLACED_SQL = `SELECT
|
||||
take.id, DATE(take.handover_date) AS handover_date,
|
||||
truck.id AS truck_id, truck.plate_number,
|
||||
dic_contract_type.dic_name AS contract_type,
|
||||
customer.customer_name
|
||||
FROM tab_truck_rent_take take
|
||||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||||
LEFT JOIN tab_dic dic_contract_type
|
||||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||||
AND dic_contract_type.dic_code = contract.contract_type
|
||||
AND dic_contract_type.is_deleted = 0
|
||||
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
||||
AND task.task_type = 3 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
|
||||
|
||||
let cachedWeeklyStats: WeeklyStats | null = null;
|
||||
let weeklyStatsLastFetch = 0;
|
||||
|
||||
async function getWeeklyStats(): Promise<WeeklyStats> {
|
||||
const now = Date.now();
|
||||
if (cachedWeeklyStats && now - weeklyStatsLastFetch < CACHE_TTL) {
|
||||
return cachedWeeklyStats;
|
||||
}
|
||||
|
||||
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE (is_operation=0 OR is_deleted=1) AND update_time >= ${WEEK_START_SQL} AND update_time < ${WEEK_END_SQL}`),
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${DELIVERED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${RETURNED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
|
||||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${REPLACED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
|
||||
]);
|
||||
|
||||
cachedWeeklyStats = {
|
||||
pendingDelivery: (pendingRows as any[])[0]?.cnt || 0,
|
||||
weeklyNew: (newRows as any[])[0]?.cnt || 0,
|
||||
weeklyRemoved: (removedRows as any[])[0]?.cnt || 0,
|
||||
weeklyDelivered: (deliveredRows as any[])[0]?.cnt || 0,
|
||||
weeklyReturned: (returnedRows as any[])[0]?.cnt || 0,
|
||||
weeklyReplaced: (replacedRows as any[])[0]?.cnt || 0,
|
||||
};
|
||||
weeklyStatsLastFetch = now;
|
||||
return cachedWeeklyStats;
|
||||
}
|
||||
|
||||
// GET /api/vehicles/summary
|
||||
app.get('/summary', async (c) => {
|
||||
const [vehicles, weekly] = await Promise.all([getVehicles(), getWeeklyStats()]);
|
||||
const summary: SummaryData = {
|
||||
totalAssets: vehicles.length,
|
||||
operating: {
|
||||
total: vehicles.filter((v) => v.status === 'Operating').length,
|
||||
self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length,
|
||||
leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length,
|
||||
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
|
||||
hanging: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Hanging').length,
|
||||
},
|
||||
inventory: {
|
||||
total: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
|
||||
},
|
||||
...weekly,
|
||||
};
|
||||
return c.json(summary);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/by-type
|
||||
app.get('/by-type', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
|
||||
const typeFilters = [
|
||||
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
|
||||
{ name: '4.5T冷链', filter: (v: Vehicle) => v.type === '4.5T' && v.model.includes('冷链') },
|
||||
{ name: '18T', filter: (v: Vehicle) => v.type === '18T' },
|
||||
{ name: '49T', filter: (v: Vehicle) => v.type === '49T' },
|
||||
{ name: '其他', filter: (v: Vehicle) => !['4.5T', '18T', '49T'].includes(v.type) },
|
||||
];
|
||||
|
||||
const result: TypeSummary[] = typeFilters.map((t) => {
|
||||
const typeVehicles = vehicles.filter(t.filter);
|
||||
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
|
||||
|
||||
const modelSummaries: ModelSummary[] = models.map((model) => {
|
||||
const modelVehicles = typeVehicles.filter((v) => v.model === model);
|
||||
// Use contractNo as batch identifier
|
||||
const batches = Array.from(new Set(modelVehicles.map((v) => v.contractNo || '未知'))).filter(Boolean);
|
||||
|
||||
return {
|
||||
model,
|
||||
...getStats(modelVehicles),
|
||||
batches: batches.map((batch) => ({
|
||||
batch,
|
||||
...getStats(modelVehicles.filter((v) => (v.contractNo || '未知') === batch)),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const typeStats = getStats(typeVehicles);
|
||||
return {
|
||||
type: t.name,
|
||||
totalAssets: typeVehicles.length,
|
||||
totalInventory: typeStats.inventory,
|
||||
totalOperating: typeStats.operating,
|
||||
inventoryRegions: typeStats.inventoryRegions,
|
||||
pending: typeStats.pending,
|
||||
weeklyDelivered: typeStats.weeklyDelivered,
|
||||
weeklyReturned: typeStats.weeklyReturned,
|
||||
weeklyReplaced: typeStats.weeklyReplaced,
|
||||
models: modelSummaries.sort((a, b) => getModelOrder(a.model) - getModelOrder(b.model)),
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/by-batch
|
||||
app.get('/by-batch', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const batches = Array.from(new Set(vehicles.map((v) => v.contractNo || '未知')))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
const result: BatchGroup[] = batches.map((batch) => {
|
||||
const batchVehicles = vehicles.filter((v) => (v.contractNo || '未知') === batch);
|
||||
const models = Array.from(new Set(batchVehicles.map((v) => v.model)));
|
||||
|
||||
return {
|
||||
batch,
|
||||
...getStats(batchVehicles),
|
||||
models: models.map((model) => {
|
||||
const modelVehicles = batchVehicles.filter((v) => v.model === model);
|
||||
return {
|
||||
model,
|
||||
type: modelVehicles[0]?.type || '',
|
||||
...getStats(modelVehicles),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/inventory-analysis
|
||||
app.get('/inventory-analysis', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
|
||||
const typeFilters = [
|
||||
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
|
||||
{ name: '4.5T冷链', filter: (v: Vehicle) => v.type === '4.5T' && v.model.includes('冷链') },
|
||||
{ name: '18T', filter: (v: Vehicle) => v.type === '18T' },
|
||||
{ name: '49T', filter: (v: Vehicle) => v.type === '49T' },
|
||||
{ name: '其他', filter: (v: Vehicle) => !['4.5T', '18T', '49T'].includes(v.type) },
|
||||
];
|
||||
|
||||
const result: InventoryTypeSummary[] = typeFilters.map((t) => {
|
||||
const typeVehicles = vehicles.filter(t.filter);
|
||||
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
|
||||
|
||||
const modelData = models.map((model) => {
|
||||
const modelVehicles = typeVehicles.filter((v) => v.model === model);
|
||||
const inventoryVehicles = modelVehicles.filter((v) => v.status === 'Inventory');
|
||||
|
||||
return {
|
||||
model,
|
||||
totalAssets: modelVehicles.length,
|
||||
totalInventory: inventoryVehicles.length,
|
||||
regions: INVENTORY_REGIONS.reduce(
|
||||
(acc, reg) => {
|
||||
acc[reg] = inventoryVehicles.filter((v) => mapInventoryRegion(v.location) === reg).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const typeInventory = typeVehicles.filter((v) => v.status === 'Inventory');
|
||||
|
||||
return {
|
||||
type: t.name,
|
||||
totalAssets: typeVehicles.length,
|
||||
totalInventory: typeInventory.length,
|
||||
models: modelData,
|
||||
regionSubtotals: INVENTORY_REGIONS.reduce(
|
||||
(acc, reg) => {
|
||||
acc[reg] = typeInventory.filter((v) => mapInventoryRegion(v.location) === reg).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/list — flat list with optional filters
|
||||
app.get('/list', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const { batch, model, location, status, category } = c.req.query();
|
||||
|
||||
let filtered = vehicles;
|
||||
if (batch && batch !== 'All') {
|
||||
filtered = filtered.filter((v) => (v.contractNo || '未知') === batch);
|
||||
}
|
||||
if (model && model !== 'All') {
|
||||
filtered = filtered.filter((v) => v.model === model);
|
||||
}
|
||||
if (location && location !== 'All') {
|
||||
// Support both display region names and inventory region names
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
filtered = filtered.filter((v) => v.location === mappedLocation);
|
||||
}
|
||||
if (status && status !== 'All') {
|
||||
filtered = filtered.filter((v) => v.status === status);
|
||||
}
|
||||
if (category) {
|
||||
if (category === 'Inventory') {
|
||||
filtered = filtered.filter((v) => v.status === 'Inventory');
|
||||
} else if (category === 'Operating') {
|
||||
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(
|
||||
filtered.map((v) => ({
|
||||
id: v.id,
|
||||
plateNumber: v.plateNumber,
|
||||
type: v.type,
|
||||
model: v.model,
|
||||
location: v.location,
|
||||
status: v.status,
|
||||
ownership: v.ownership,
|
||||
contractNo: v.contractNo,
|
||||
customerName: v.customerName,
|
||||
subjectOrg: v.subjectOrg,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||
app.get('/weekly-detail', async (c) => {
|
||||
const type = c.req.query('type');
|
||||
let sql: string;
|
||||
if (type === 'delivered') {
|
||||
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||
} else if (type === 'returned') {
|
||||
sql = `${RETURNED_SQL} AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL} ORDER BY r.return_date DESC`;
|
||||
} else if (type === 'replaced') {
|
||||
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||||
} else if (type === 'pending') {
|
||||
sql = `SELECT truck.id AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
||||
} else {
|
||||
return c.json([]);
|
||||
}
|
||||
const [rows] = await pool.query<any[]>(sql);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/refresh — force cache refresh
|
||||
app.get('/refresh', async (c) => {
|
||||
lastFetchTime = 0;
|
||||
const vehicles = await getVehicles();
|
||||
return c.json({ message: 'Cache refreshed', count: vehicles.length });
|
||||
});
|
||||
|
||||
export default app;
|
||||
147
src/server/types.ts
Normal file
147
src/server/types.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
export interface VehicleRow {
|
||||
id: number;
|
||||
车牌号: string;
|
||||
vin: string;
|
||||
车辆品牌: string;
|
||||
车辆型号: string;
|
||||
车辆颜色: string;
|
||||
租赁公司: string;
|
||||
车辆归属状态Label: string | null;
|
||||
车辆型号Label: string | null;
|
||||
库存区域: string | null;
|
||||
车辆租赁状态: string | null;
|
||||
车辆租赁状态Label: string | null;
|
||||
是否营运: number;
|
||||
省: string | null;
|
||||
市: string | null;
|
||||
纬度: number | null;
|
||||
经度: number | null;
|
||||
车辆品牌Label: string | null;
|
||||
合同ID: number | null;
|
||||
合同编码: string | null;
|
||||
客户名称: string | null;
|
||||
合同归属公司: string | null;
|
||||
合同归属部门: string | null;
|
||||
主体: string | null;
|
||||
项目名称: string | null;
|
||||
客户经理: string | null;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
id: number;
|
||||
plateNumber: string;
|
||||
vin: string;
|
||||
type: string;
|
||||
model: string;
|
||||
color: string;
|
||||
location: string;
|
||||
region: string;
|
||||
status: 'Operating' | 'Inventory' | 'Abnormal';
|
||||
ownership: string;
|
||||
rentCompany: string;
|
||||
contractNo: string | null;
|
||||
customerName: string | null;
|
||||
orgName: string | null;
|
||||
departmentName: string | null;
|
||||
subjectOrg: string | null;
|
||||
projectName: string | null;
|
||||
customerManager: string | null;
|
||||
brandLabel: string | null;
|
||||
}
|
||||
|
||||
export interface SummaryData {
|
||||
totalAssets: number;
|
||||
operating: {
|
||||
total: number;
|
||||
self: number;
|
||||
leased: number;
|
||||
public: number;
|
||||
hanging: number;
|
||||
};
|
||||
inventory: {
|
||||
total: number;
|
||||
inStock: number;
|
||||
abnormal: number;
|
||||
};
|
||||
pendingDelivery: number;
|
||||
weeklyNew: number;
|
||||
weeklyRemoved: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
}
|
||||
|
||||
export interface TypeSummary {
|
||||
type: string;
|
||||
totalAssets: number;
|
||||
totalInventory: number;
|
||||
totalOperating: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
models: ModelSummary[];
|
||||
}
|
||||
|
||||
export interface ModelSummary {
|
||||
model: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
batches: BatchSummary[];
|
||||
}
|
||||
|
||||
export interface BatchSummary {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
}
|
||||
|
||||
export interface BatchGroup {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
models: {
|
||||
model: string;
|
||||
type: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface InventoryTypeSummary {
|
||||
type: string;
|
||||
totalAssets: number;
|
||||
totalInventory: number;
|
||||
models: {
|
||||
model: string;
|
||||
totalAssets: number;
|
||||
totalInventory: number;
|
||||
regions: Record<string, number>;
|
||||
}[];
|
||||
regionSubtotals: Record<string, number>;
|
||||
}
|
||||
Reference in New Issue
Block a user