feat(assets): migrate asset views and reconcile inventory drilldowns
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import app from '../vehicles.js';
|
||||
import { vehicleRepository } from './repository.js';
|
||||
import type { Vehicle } from '../../types.js';
|
||||
|
||||
// Repository stubs keep this regression entirely offline.
|
||||
test('asset drill-down preserves company, region scope, vehicle type and actual cities', async (t) => {
|
||||
const make = (id: string, overrides: Partial<Vehicle> = {}): Vehicle => ({
|
||||
id, plateNumber: `TEST-${id}`, vin: id, type: '4.5T', model: '测试普货', color: '白',
|
||||
location: '嘉兴', region: '嘉兴', province: '浙江省', city: `测试城市${id}`,
|
||||
status: 'Operating', operationStatus: '1', ownership: 'Rental', rentCompany: '',
|
||||
contractNo: null, customerName: null, orgName: null, departmentName: null,
|
||||
subjectOrg: '公司甲', projectName: null, customerManager: null, managerId: null, brandLabel: null,
|
||||
...overrides,
|
||||
});
|
||||
const vehicles = Array.from({ length: 9 }, (_, i) => make(String(i + 1)));
|
||||
vehicles.push(make('10', { operationStatus: '3', status: 'Inventory' }));
|
||||
vehicles.push(make('11', { type: '挂车', model: '测试挂车' }));
|
||||
vehicles.push(make('12', { subjectOrg: '公司乙' }));
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
|
||||
t.mock.method(vehicleRepository, 'getWeeklyDetailRows', async () => [
|
||||
{ truck_id: '1', plate_number: 'TEST-1', handover_date: null, contract_type: null, customer_name: null },
|
||||
{ truck_id: '12', plate_number: 'TEST-12', handover_date: null, contract_type: null, customer_name: null },
|
||||
]);
|
||||
const read = async (path: string) => {
|
||||
const response = await app.request(path);
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
};
|
||||
const regions = await read('/region-stats?subject=公司甲');
|
||||
assert.equal(regions[0].totalAssets, 10);
|
||||
assert.equal(regions[0].cities.length, 10, 'cities beyond the first eight stay individually addressable');
|
||||
const total = await read('/list?source=region&location=华东&subject=公司甲');
|
||||
assert.equal(total.length, regions[0].totalAssets);
|
||||
assert.ok(total.every((v: Vehicle) => v.id !== '10' && v.id !== '12'));
|
||||
const light = await read('/list?source=region&location=华东&subject=公司甲&vehicleType=4.5T');
|
||||
assert.equal(light.length, 9);
|
||||
const other = await read('/list?source=region&location=华东&subject=公司甲&vehicleType=区域其他');
|
||||
assert.deepEqual(other.map((v: Vehicle) => v.id), ['11']);
|
||||
const weekly = await read('/weekly-detail?type=delivered&subject=公司甲');
|
||||
assert.deepEqual(weekly.map((v: {truck_id: string}) => v.truck_id), ['1']);
|
||||
});
|
||||
|
||||
test('inventory brand drill-down matches each same-model row, including unknown brand', async (t) => {
|
||||
const make = (id: string, brandLabel: string | null, subjectOrg = '公司甲'): Vehicle => ({
|
||||
id, plateNumber: `TEST-${id}`, vin: id, type: '挂车', model: '挂车', color: '白',
|
||||
location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市',
|
||||
status: 'Inventory', operationStatus: '3', ownership: 'Rental', rentCompany: '',
|
||||
contractNo: null, customerName: null, orgName: null, departmentName: null,
|
||||
subjectOrg, projectName: null, customerManager: null, managerId: null, brandLabel,
|
||||
});
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => [make('1', '明威'), make('2', '明威'), make('3', '万风'), make('4', null), make('5', '明威', '公司乙')]);
|
||||
const summary = await (await app.request('/inventory-stats?subject=公司甲')).json();
|
||||
for (const row of summary) {
|
||||
const query = new URLSearchParams({ source: 'asset', subject: '公司甲', category: 'Inventory', model: row.model, location: row.city, vehicleType: row.type, brand: row.brand });
|
||||
const result = await app.request(`/list?${query}`);
|
||||
assert.equal(result.status, 200);
|
||||
const detail = await result.json();
|
||||
assert.equal(detail.length, row.quantity);
|
||||
assert.ok(detail.every((vehicle: { brandLabel: string | null }) => (vehicle.brandLabel || '未知') === row.brand));
|
||||
}
|
||||
});
|
||||
|
||||
test('inventory composition and color variants reconcile with permission-filtered list keys', async (t) => {
|
||||
const make = (id: string, operationStatus: string, overrides: Partial<Vehicle> = {}): Vehicle => ({
|
||||
id,plateNumber:`TEST-${id}`,vin:id,type:'18T',model:'现代18T双飞翼(羚牛)',color:'白',location:'嘉兴',region:'嘉兴',province:'浙江省',city:'嘉兴市',status:'Inventory',operationStatus,ownership:'Self',rentCompany:'',contractNo:null,customerName:null,orgName:null,departmentName:null,subjectOrg:'公司甲',projectName:null,customerManager:null,managerId:null,brandLabel:'帕力安牌',...overrides,
|
||||
});
|
||||
t.mock.method(vehicleRepository,'getVehicles',async () => [make('1','3'),make('2','可运营',{status:'Abnormal',abnormalMove:{type:'维修'} as Vehicle['abnormalMove']}),make('3','4',{status:'Pending',color:'红'}),make('4','待运营'),make('5','1'),make('6','2'),make('7','3',{subjectOrg:'公司乙'})]);
|
||||
const stats = await (await app.request('/inventory-stats?subject=公司甲')).json();
|
||||
assert.equal(stats.length,1);
|
||||
const row = stats[0];
|
||||
assert.equal(row.quantity,4); assert.equal(row.available,2); assert.equal(row.awaiting,2); assert.equal(row.unclassified,0);
|
||||
assert.equal(row.abnormal,1); assert.equal(row.pending,1); assert.deepEqual(row.abnormalTypes,{维修:1});
|
||||
assert.equal(row.variants['白色'].quantity,3); assert.equal(row.variants['红色'].quantity,1);
|
||||
const list = await (await app.request('/list?subject=公司甲&category=Inventory')).json();
|
||||
assert.equal(list.length,4);
|
||||
assert.ok(list.every((vehicle: {inventoryKey:string}) => vehicle.inventoryKey === JSON.stringify([row.region,row.city,row.brand,row.type,row.model])));
|
||||
assert.equal(list.filter((vehicle: {color:string}) => vehicle.color === '红').length,1);
|
||||
});
|
||||
Reference in New Issue
Block a user