feat(assets): migrate asset views and reconcile inventory drilldowns

This commit is contained in:
shishengliang
2026-09-23 14:16:22 +08:00
parent b0cba26fbb
commit 822aabf2ca
77 changed files with 14669 additions and 3856 deletions
+42 -57
View File
@@ -1,3 +1,4 @@
import { inventoryKey, inventoryOperation, inventoryColor, emptyInventoryCounts, type InventoryCounts } from '../../shared/assets/inventory.js';
import { Hono } from 'hono';
import type {
Vehicle,
@@ -306,24 +307,8 @@ app.get('/region-stats', async (c) => {
}))
.sort((a, b) => b.totalAssets - a.totalAssets);
// Top 8 cities + merge rest into "其他"
const topCities = allCities.slice(0, 8);
const restCities = allCities.slice(8);
if (restCities.length > 0) {
const restVehicles = restCities.flatMap((c) => {
const key = c.city;
return cityMap.get(key) || [];
});
topCities.push({
city: '其他',
totalAssets: restCities.reduce((s, c) => s + c.totalAssets, 0),
operatingCount: restCities.reduce((s, c) => s + c.operatingCount, 0),
pendingCount: restCities.reduce((s, c) => s + (c.pendingCount || 0), 0),
customers: Array.from(new Set(restVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
typeBreakdown: getTypeBreakdown(restVehicles),
});
}
const cities = topCities;
// The UI uses collapsible city sections, so keep actual cities addressable for drill-down.
const cities = allCities;
return {
region,
@@ -372,9 +357,11 @@ app.get('/customer-stats', async (c) => {
// GET /api/vehicles/list — flat list with optional filters
app.get('/list', async (c) => {
const vehicles = await getVehiclesForUser(c);
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
let filtered = vehicles;
let filtered = c.req.query('source') === 'region'
? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v))
: vehicles;
// attendance filter: active = day_mileage > 0, idle = day_mileage = 0 (only for Operating vehicles)
if (attendance === 'active' || attendance === 'idle') {
@@ -389,7 +376,9 @@ app.get('/list', async (c) => {
}
}
if (vehicleType) {
if (VEHICLE_TYPE_FILTERS[vehicleType]) {
if (vehicleType === '区域其他' && c.req.query('source') === 'region') {
filtered = filtered.filter(v => !['4.5T', '18T', '49T'].includes(v.type));
} else if (VEHICLE_TYPE_FILTERS[vehicleType]) {
filtered = filtered.filter(VEHICLE_TYPE_FILTERS[vehicleType]);
} else if (vehicleType === '4.5T') {
filtered = filtered.filter((v) => v.type === '4.5T');
@@ -397,6 +386,9 @@ app.get('/list', async (c) => {
filtered = filtered.filter((v) => v.type === vehicleType);
}
}
if (brand) {
filtered = filtered.filter((v) => (v.brandLabel || '未知') === brand);
}
if (batch && batch !== 'All') {
filtered = filtered.filter((v) => (v.contractNo || '未知') === batch);
}
@@ -450,6 +442,9 @@ app.get('/list', async (c) => {
type: v.type,
model: v.model,
location: v.location,
color: v.color,
operationStatus: v.operationStatus,
inventoryKey: inventoryKey(inventoryDimensions(v)),
province: v.province,
city: v.city,
status: v.status,
@@ -468,44 +463,34 @@ app.get('/list', async (c) => {
);
});
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
// Inventory keeps its existing aggregate visibility; vehicle details retain user permissions.
const INVENTORY_TYPE_NAMES: Record<string, string> = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他' };
function inventoryDimensions(v: Vehicle) {
return { region: mapMacroRegion(v.province, v.city), city: resolveCity(v.city, v.province), brand: v.brandLabel || '未知', type: INVENTORY_TYPE_NAMES[classifyVehicleType({ ...v, model: v.model || "" })], model: v.model || "未标注型号" };
}
app.get('/inventory-stats', async (c) => {
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles());
const inventory = vehicles.filter((v) => isInventoryVehicle(v));
const TYPE_NAME_MAP: Record<string, string> = {
t4_5: '4.5T普货',
t4_5c: '4.5T冷链',
t18: '18T',
t49: '49T',
trailer: '挂车',
other: '其他',
};
const groups = new Map<string, { quantity: number; abnormal: number; pending: number }>();
for (const v of inventory) {
const typeCategory = classifyVehicleType(v);
const typeName = TYPE_NAME_MAP[typeCategory];
const region = mapMacroRegion(v.province, v.city);
const city = resolveCity(v.city, v.province);
const brand = v.brandLabel || '未知';
const model = v.model;
const key = `${region}|${city}|${brand}|${typeName}|${model}`;
const counts = groups.get(key) || { quantity: 0, abnormal: 0, pending: 0 };
counts.quantity += 1;
if (isInventoryAbnormal(v)) counts.abnormal += 1;
if (isInventoryPending(v)) counts.pending += 1;
groups.set(key, counts);
const groups = new Map<string, ReturnType<typeof inventoryDimensions> & InventoryCounts & { batch: string; variants: Record<string, InventoryCounts> }>();
for (const v of vehicles.filter(isInventoryVehicle)) {
const dimensions = inventoryDimensions(v);
const key = inventoryKey(dimensions);
const row = groups.get(key) || { ...dimensions, batch: v.model, ...emptyInventoryCounts(), variants: {} };
const color = inventoryColor(v.color);
const variant = row.variants[color] || emptyInventoryCounts();
for (const count of [row, variant]) {
count.quantity += 1;
count[inventoryOperation(v.operationStatus)] += 1;
if (isInventoryPending(v)) count.pending += 1;
if (isInventoryAbnormal(v)) {
count.abnormal += 1;
const type = v.abnormalMove?.type || '未提供';
count.abnormalTypes[type] = (count.abnormalTypes[type] || 0) + 1;
}
}
row.variants[color] = variant;
groups.set(key, row);
}
const result = Array.from(groups.entries())
.map(([key, counts]) => {
const [region, city, brand, type, model] = key.split('|');
return { region, city, brand, type, model, batch: model, ...counts };
})
.sort((a, b) => b.quantity - a.quantity);
return c.json(result);
return c.json([...groups.values()].sort((a, b) => b.quantity - a.quantity));
});
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
@@ -524,7 +509,7 @@ app.get('/weekly-detail', async (c) => {
const hasModelFilter = model && model !== 'All';
const hasBatchFilter = batch && batch !== 'All';
const hasLocationFilter = location && location !== 'All';
if (hasModelFilter || hasBatchFilter || hasLocationFilter) {
if (hasModelFilter || hasBatchFilter || hasLocationFilter || getSubjectParam(c)) {
const vehicles = await getVehiclesForUser(c);
let pool2 = vehicles;
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);