feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful

Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
This commit is contained in:
shishengliang
2026-09-28 12:29:51 +08:00
parent 6de77adb47
commit f1b3f8d3da
69 changed files with 3871 additions and 536 deletions
+75 -9
View File
@@ -37,6 +37,13 @@ import {
} from './vehicles/utils.js';
import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './vehicles/repository.js';
import type { AssetAnalysisMetric, AssetAnalysisScope } from '../../shared/assets/analytics.js';
import {
abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis, loadMovementApprovalTimes,
matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric,
validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope,
} from './vehicles/analytics.js';
export { mapRegion } from './vehicles/model.js';
const app = new Hono();
@@ -354,6 +361,54 @@ app.get('/customer-stats', async (c) => {
return c.json(result);
});
/** Analysis uses the same permission-filtered collection and exact entry scope as details. */
app.on(['GET', 'POST'], '/analysis', async (c) => {
const body = c.req.method === 'POST' ? await c.req.json().catch(() => null) : {};
if (!body || typeof body !== 'object' || Array.isArray(body)) return c.json({ error: '统计范围无效' }, 400);
const scope = body.scope || {};
if (!validAnalysisScope(scope) || (body.inventoryKeys !== undefined && (!Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')))) {
return c.json({ error: '统计范围无效' }, 400);
}
let vehicles = await filterVehicleListScope(await getVehiclesForUser(c), scope);
if (body.inventoryKeys) vehicles = await filterVehicleListScope(vehicles, { inventoryKeys: body.inventoryKeys });
const approvals = await loadMovementApprovalTimes(vehicles);
return c.json(buildAssetAnalysis(vehicles, approvals, Date.now()));
});
app.post('/analysis/details', async (c) => {
const body = await c.req.json().catch(() => null);
const filterValues = body?.filterValues === undefined ? {} : body.filterValues;
const metrics: AssetAnalysisMetric[] = ['assets', 'operating', 'inventory', 'abnormal', 'pending'];
if (!body || !metrics.includes(body.metric) || !validAnalysisFilters(body.filters || {}) || !validAnalysisScope(body.scope || {})
|| !validAnalysisFilterValues(filterValues)
|| (body.inventoryKeys !== undefined && (!Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')))) {
return c.json({ error: '下钻条件无效' }, 400);
}
const now = body.generatedAt === undefined ? Date.now() : typeof body.generatedAt === 'string' ? Date.parse(body.generatedAt) : NaN;
if (!Number.isFinite(now) || now > Date.now() + 60000) return c.json({ error: '统计时间无效,请刷新分析' }, 400);
let vehicles = await filterVehicleListScope(await getVehiclesForUser(c), body.scope || {});
if (body.inventoryKeys) vehicles = await filterVehicleListScope(vehicles, { inventoryKeys: body.inventoryKeys });
const approvals = await loadMovementApprovalTimes(vehicles);
const filtered = vehicles.filter(vehicle => {
const cell = analysisCellForVehicle(vehicle, approvals, now);
return matchesAnalysisMetric(cell, body.metric)
&& matchesAnalysisFilters(cell, body.filters || {})
&& matchesAnalysisFilterValues(cell, filterValues);
});
return c.json(filtered.map(vehicle => {
const result = vehicleListItem(vehicle);
const approvedAt = approvals.byMoveId.get(vehicle.abnormalMove?.id || '');
return {
...result,
abnormalMove: result.abnormalMove ? {
...result.abnormalMove,
approvalPassedAt: approvedAt || null,
elapsedDays: abnormalElapsedDays(approvedAt, now),
} : null,
};
}));
});
// POST uses a JSON scope to avoid URL length limits for inventory subtotal drilldown.
app.on(['GET', 'POST'], '/list', async (c) => {
let inventoryKeys: Set<string> | undefined;
@@ -365,12 +420,20 @@ app.on(['GET', 'POST'], '/list', async (c) => {
inventoryKeys = new Set(body.inventoryKeys);
}
const vehicles = await getVehiclesForUser(c);
const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
const filtered = await filterVehicleListScope(vehicles, { ...c.req.query(), inventoryKeys: inventoryKeys ? [...inventoryKeys] : undefined });
return c.json(filtered.map(vehicleListItem));
});
let filtered = c.req.query('source') === 'region'
async function filterVehicleListScope(vehicles: Vehicle[], scope: AssetAnalysisScope): Promise<Vehicle[]> {
const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = scope;
let filtered = scope.source === 'region'
? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v))
: vehicles;
const subject = (scope.subject || '').trim();
if (subject) filtered = filtered.filter(v => v.subjectOrg === subject);
const inventoryKeys = scope.inventoryKeys ? new Set(scope.inventoryKeys) : undefined;
if (inventoryKeys) {
filtered = filtered.filter(v => isInventoryVehicle(v) && inventoryKeys.has(inventoryKey(inventoryDimensions(v))));
}
@@ -388,7 +451,7 @@ app.on(['GET', 'POST'], '/list', async (c) => {
}
}
if (vehicleType) {
if (vehicleType === '区域其他' && c.req.query('source') === 'region') {
if (vehicleType === '区域其他' && scope.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]);
@@ -408,7 +471,7 @@ app.on(['GET', 'POST'], '/list', async (c) => {
filtered = filtered.filter((v) => v.model === model);
}
if (location && location !== 'All') {
filtered = filterByLocation(filtered, location, c.req.query('source'));
filtered = filterByLocation(filtered, location, scope.source);
}
if (status && status !== 'All') {
filtered = filtered.filter((v) => v.status === status);
@@ -446,8 +509,11 @@ app.on(['GET', 'POST'], '/list', async (c) => {
filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车')));
}
return c.json(
filtered.map((v) => ({
return filtered;
}
function vehicleListItem(v: Vehicle) {
return {
id: v.id,
plateNumber: v.plateNumber,
vin: v.vin,
@@ -471,9 +537,9 @@ app.on(['GET', 'POST'], '/list', async (c) => {
customerManager: v.customerManager,
brandLabel: v.brandLabel,
orgName: v.orgName,
})),
);
});
};
}
// 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: '其他' };