Merge Profile0928 asset analysis and permission previews into main
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -55,13 +55,15 @@ export const gapStore = {
|
||||
[userId, fingerprint, demand, reason],
|
||||
);
|
||||
},
|
||||
async list(userId: string | null): Promise<DemandGap[]> {
|
||||
await ensure();
|
||||
await purge();
|
||||
async list(userId: string | null, options: { readOnly?: boolean } = {}): Promise<DemandGap[]> {
|
||||
if (!options.readOnly) {
|
||||
await ensure();
|
||||
await purge();
|
||||
}
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id,demand,reason,status,occurrences,
|
||||
DATE_FORMAT(first_seen,'%Y-%m-%dT%H:%i:%sZ') firstSeen, DATE_FORMAT(last_seen,'%Y-%m-%dT%H:%i:%sZ') lastSeen
|
||||
FROM bi_asset_ai_gaps ${userId ? "WHERE user_id = ?" : ""} ORDER BY last_seen DESC LIMIT 200`,
|
||||
FROM bi_asset_ai_gaps WHERE ${userId ? "user_id = ? AND " : ""}last_seen >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 90 DAY) ORDER BY last_seen DESC LIMIT 200`,
|
||||
userId ? [userId] : [],
|
||||
);
|
||||
return rows as DemandGap[];
|
||||
|
||||
@@ -59,6 +59,7 @@ router.get("/gaps", async (c) => {
|
||||
return c.json({
|
||||
items: await gapStore.list(
|
||||
c.req.query("scope") === "all" ? null : user.userId,
|
||||
{ readOnly: !!user.preview },
|
||||
),
|
||||
canManage: manage,
|
||||
});
|
||||
@@ -502,8 +503,10 @@ router.post("/ask", async (c) => {
|
||||
if (unanswered?.length) demand = `${unanswered.map(item => `${item.question}:${item.reason}`).join(";")}(原问题:${demand})`.slice(0, 600);
|
||||
let recorded = false;
|
||||
try {
|
||||
await gapStore.record(user.userId, demand, gapReason);
|
||||
recorded = true;
|
||||
if (!user.preview) {
|
||||
await gapStore.record(user.userId, demand, gapReason);
|
||||
recorded = true;
|
||||
}
|
||||
} catch {
|
||||
/* Report failure explicitly, never pretend it was persisted. */
|
||||
}
|
||||
@@ -518,8 +521,10 @@ router.post("/ask", async (c) => {
|
||||
if (!controller.signal.aborted) {
|
||||
let recorded = false;
|
||||
try {
|
||||
await gapStore.record(user.userId, demand, "generation");
|
||||
recorded = true;
|
||||
if (!user.preview) {
|
||||
await gapStore.record(user.userId, demand, "generation");
|
||||
recorded = true;
|
||||
}
|
||||
} catch {
|
||||
/* No raw error or query payload in logs. */
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ const owner = (u: AuthUser) =>
|
||||
u.depCode,
|
||||
u.permissionLevel,
|
||||
[...u.roles].sort(),
|
||||
[...(u.managerIds ?? [u.userId])].sort(),
|
||||
u.preview?.actorId ?? null,
|
||||
u.preview?.sessionId ?? null,
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
|
||||
@@ -30,8 +30,19 @@ export interface EleDependencies {
|
||||
parse?: (buf: ArrayBuffer) => ParsedRow[];
|
||||
}
|
||||
|
||||
function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined {
|
||||
return c.get?.('user') as AuthUser | undefined;
|
||||
}
|
||||
|
||||
export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
|
||||
const { db, ensureTable } = deps;
|
||||
app.onError((error, c) => {
|
||||
if (currentUser(c)?.preview && ['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes((error as { code?: string }).code || ''))
|
||||
return c.json({ message: '充电数据存储尚未就绪,权限预览不会初始化或更新表结构,请联系管理员。', code: 'PREVIEW_ELE_STORAGE_UNAVAILABLE' }, 503);
|
||||
console.error(error);
|
||||
return c.text('Internal Server Error', 500);
|
||||
});
|
||||
|
||||
const parse = deps.parse ?? parseSheet;
|
||||
|
||||
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
|
||||
@@ -96,7 +107,7 @@ export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
|
||||
|
||||
// GET /api/ele/list — 分页列表(最新优先)
|
||||
app.get('/list', async (c) => {
|
||||
await ensureTable();
|
||||
if (!currentUser(c)?.preview) await ensureTable();
|
||||
const page = Math.max(1, Number(c.req.query('page')) || 1);
|
||||
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
|
||||
const filter = buildListFilter({
|
||||
@@ -113,13 +124,13 @@ export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
|
||||
|
||||
// GET /api/ele/batches — 批次列表
|
||||
app.get('/batches', async (c) => {
|
||||
await ensureTable();
|
||||
if (!currentUser(c)?.preview) await ensureTable();
|
||||
return c.json({ items: await listBatches(db) });
|
||||
});
|
||||
|
||||
// GET /api/ele/aggregate — 聚合统计
|
||||
app.get('/aggregate', async (c) => {
|
||||
await ensureTable();
|
||||
if (!currentUser(c)?.preview) await ensureTable();
|
||||
const [overall, daily] = await Promise.all([
|
||||
aggregateByVehicleKind(db),
|
||||
aggregateDaily30d(db),
|
||||
|
||||
@@ -33,6 +33,13 @@ function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined
|
||||
|
||||
export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): void {
|
||||
const { db, ensureTable } = deps;
|
||||
app.onError((error, c) => {
|
||||
if (currentUser(c)?.preview && ['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes((error as { code?: string }).code || ''))
|
||||
return c.json({ message: '反馈存储尚未就绪,权限预览不会初始化或更新表结构,请联系管理员。', code: 'PREVIEW_FEEDBACK_STORAGE_UNAVAILABLE' }, 503);
|
||||
console.error(error);
|
||||
return c.text('Internal Server Error', 500);
|
||||
});
|
||||
|
||||
const uploadImage = deps.uploadImage ?? uploadFeedbackImage;
|
||||
|
||||
// POST /api/feedback/submit — 提交反馈
|
||||
@@ -98,7 +105,7 @@ export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): v
|
||||
|
||||
// GET /api/feedback/mine — 当前用户的反馈历史
|
||||
app.get('/mine', async (c) => {
|
||||
await ensureTable();
|
||||
if (!currentUser(c)?.preview) await ensureTable();
|
||||
const user = currentUser(c);
|
||||
if (!user?.userId) return c.json({ items: [] });
|
||||
return c.json({ items: await listFeedbackForUser(db, user.userId) });
|
||||
@@ -106,7 +113,7 @@ export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): v
|
||||
|
||||
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||
app.get('/list', async (c) => {
|
||||
await ensureTable();
|
||||
if (!currentUser(c)?.preview) await ensureTable();
|
||||
const user = currentUser(c);
|
||||
if (!canManageFeedback(user?.roles)) {
|
||||
return c.json({ ok: false, message: '无权限' }, 403);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import dailyReportRouter from './daily-report.js';
|
||||
import { DailyReportStorageUnavailable, getDailyReportSnapshot, listDailyReportHistory } from './daily-report-store.js';
|
||||
|
||||
test('preview report storage reads never initialize or write tables, including missing-table failures', async (t) => {
|
||||
const statements: string[] = [];
|
||||
let missing = false;
|
||||
t.mock.method(pool, 'query', async (sql: string) => {
|
||||
statements.push(sql);
|
||||
if (missing) throw Object.assign(new Error('missing'), { code: 'ER_NO_SUCH_TABLE' });
|
||||
return [[], []];
|
||||
});
|
||||
assert.equal(await getDailyReportSnapshot('2026-09-28', { readOnly: true }), null);
|
||||
assert.deepEqual(await listDailyReportHistory(90, { readOnly: true }), { items: [], latestDate: null });
|
||||
missing = true;
|
||||
await assert.rejects(getDailyReportSnapshot('2026-09-28', { readOnly: true }), DailyReportStorageUnavailable);
|
||||
await assert.rejects(listDailyReportHistory(90, { readOnly: true }), DailyReportStorageUnavailable);
|
||||
assert.equal(statements.length, 4);
|
||||
assert.ok(statements.every(sql => /^SELECT\b/.test(sql)));
|
||||
});
|
||||
|
||||
test('both preview report endpoints return an actionable error without DDL when storage is absent', async (t) => {
|
||||
const statements: string[] = [];
|
||||
t.mock.method(pool, 'query', async (sql: string) => {
|
||||
statements.push(sql);
|
||||
throw Object.assign(new Error('missing'), { code: 'ER_NO_SUCH_TABLE' });
|
||||
});
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user', { userId: 'target', userName: 'Target', loginName: 'target', depCode: '', depName: '',
|
||||
roles: [], permissionLevel: 'personal', preview: { actorId: 'actor', sessionId: 'preview' } });
|
||||
await next();
|
||||
});
|
||||
app.route('/reports', dailyReportRouter);
|
||||
for (const path of ['/reports?date=2026-09-28', '/reports/history']) {
|
||||
const response = await app.request(path);
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal((await response.json()).code, 'PREVIEW_REPORT_STORAGE_UNAVAILABLE');
|
||||
}
|
||||
assert.equal(statements.length, 2);
|
||||
assert.ok(statements.every(sql => /^SELECT\b/.test(sql)));
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
mileageBand,
|
||||
roundMileage,
|
||||
} from './daily-report-model.js';
|
||||
import { getDailyReportSnapshot, saveDailyReportSnapshot } from './daily-report-store.js';
|
||||
import { getDailyReportSnapshot, saveDailyReportSnapshot, type DailyReportReadOptions } from './daily-report-store.js';
|
||||
|
||||
interface AssessmentRow extends RowDataPacket {
|
||||
target_id: number;
|
||||
@@ -264,8 +264,8 @@ export async function generateDailyMileageReport(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrGenerateDailyMileageReport(reportDate: string): Promise<DailyMileageReport> {
|
||||
const snapshot = await getDailyReportSnapshot(reportDate);
|
||||
export async function getOrGenerateDailyMileageReport(reportDate: string, options: DailyReportReadOptions = {}): Promise<DailyMileageReport> {
|
||||
const snapshot = await getDailyReportSnapshot(reportDate, options);
|
||||
if (snapshot) return snapshot;
|
||||
return generateDailyMileageReport(reportDate, 'PREVIEW', 'LIVE');
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
loadDailyReportPayload,
|
||||
updateDailyReportPayload,
|
||||
} from './repository.js';
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db/mysql.js';
|
||||
import { ensureDailyReportTable, DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js';
|
||||
import { ensureDailyReportTable } from '../../db/schema/mileage-report.js';
|
||||
import type {
|
||||
DailyMileageReport,
|
||||
MileageReportHistoryItem,
|
||||
@@ -15,6 +14,22 @@ import type {
|
||||
MileageReportSource,
|
||||
} from '../../../shared/mileage/daily-report.js';
|
||||
|
||||
export type DailyReportReadOptions = { readOnly?: boolean };
|
||||
export class DailyReportStorageUnavailable extends Error {
|
||||
constructor() { super('日报归档尚未初始化,权限预览不会创建存储,请联系管理员。'); }
|
||||
}
|
||||
|
||||
async function readReportRows<T>(read: () => Promise<T>, options: DailyReportReadOptions): Promise<T> {
|
||||
if (!options.readOnly) await ensureDailyReportTable();
|
||||
try {
|
||||
return await read();
|
||||
} catch (error) {
|
||||
if (options.readOnly && (error as { code?: string })?.code === 'ER_NO_SUCH_TABLE')
|
||||
throw new DailyReportStorageUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePayload(value: unknown): DailyMileageReport | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value as DailyMileageReport;
|
||||
@@ -26,9 +41,8 @@ function parsePayload(value: unknown): DailyMileageReport | null {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDailyReportSnapshot(reportDate: string): Promise<DailyMileageReport | null> {
|
||||
await ensureDailyReportTable();
|
||||
const rows = await loadDailyReportPayload(pool, { reportDate });
|
||||
export async function getDailyReportSnapshot(reportDate: string, options: DailyReportReadOptions = {}): Promise<DailyMileageReport | null> {
|
||||
const rows = await readReportRows(() => loadDailyReportPayload(pool, { reportDate }), options);
|
||||
return parsePayload(rows[0]?.report_payload);
|
||||
}
|
||||
|
||||
@@ -68,10 +82,9 @@ export async function updateDailyReportSnapshotPayload(report: DailyMileageRepor
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
export async function listDailyReportHistory(limit = 90): Promise<MileageReportHistoryResponse> {
|
||||
await ensureDailyReportTable();
|
||||
export async function listDailyReportHistory(limit = 90, options: DailyReportReadOptions = {}): Promise<MileageReportHistoryResponse> {
|
||||
const safeLimit = Math.min(366, Math.max(1, limit));
|
||||
const rows = await listDailyReportHistoryRows(pool, { safeLimit });
|
||||
const rows = await readReportRows(() => listDailyReportHistoryRows(pool, { safeLimit }), options);
|
||||
const items: MileageReportHistoryItem[] = rows.map(row => ({
|
||||
reportDate: row.report_date,
|
||||
status: row.status,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { getOrGenerateDailyMileageReport } from './daily-report-service.js';
|
||||
import { listDailyReportHistory } from './daily-report-store.js';
|
||||
import { DailyReportStorageUnavailable, listDailyReportHistory } from './daily-report-store.js';
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
function isYmd(value: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||
@@ -11,8 +12,10 @@ function isYmd(value: string): boolean {
|
||||
app.get('/history', async (c) => {
|
||||
try {
|
||||
const limit = Number(c.req.query('limit')) || 90;
|
||||
return c.json(await listDailyReportHistory(limit));
|
||||
return c.json(await listDailyReportHistory(limit, { readOnly: !!c.get('user')?.preview }));
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DailyReportStorageUnavailable)
|
||||
return c.json({ message: error.message, code: 'PREVIEW_REPORT_STORAGE_UNAVAILABLE' }, 503);
|
||||
console.error('mileage daily report history error:', error);
|
||||
return c.json({ items: [], latestDate: null }, 500);
|
||||
}
|
||||
@@ -22,8 +25,10 @@ app.get('/', async (c) => {
|
||||
const date = c.req.query('date') || '';
|
||||
if (!isYmd(date)) return c.json({ message: 'date 必须为 YYYY-MM-DD' }, 400);
|
||||
try {
|
||||
return c.json(await getOrGenerateDailyMileageReport(date));
|
||||
return c.json(await getOrGenerateDailyMileageReport(date, { readOnly: !!c.get('user')?.preview }));
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DailyReportStorageUnavailable)
|
||||
return c.json({ message: error.message, code: 'PREVIEW_REPORT_STORAGE_UNAVAILABLE' }, 503);
|
||||
console.error('mileage daily report error:', error);
|
||||
return c.json({ message: error instanceof Error ? error.message : '日报生成失败' }, 500);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type { Vehicle } from '../../types.js';
|
||||
import type { AssetAnalysisResponse, AssetAnalysisScope } from '../../../shared/assets/analytics.js';
|
||||
import app from './routes.js';
|
||||
import { vehicleRepository } from './repository.js';
|
||||
import {
|
||||
abnormalAgeBucket, abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis,
|
||||
matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric,
|
||||
validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope,
|
||||
type ApprovalTimes,
|
||||
} from './analytics.js';
|
||||
|
||||
const NOW = Date.parse('2026-09-28T12:00:00+08:00');
|
||||
const approvals: ApprovalTimes = { available: true, byMoveId: new Map() };
|
||||
const make = (id: string, overrides: Partial<Vehicle> = {}): Vehicle => ({
|
||||
id, plateNumber: `TEST-${id}`, vin: `PRIVATE-VIN-${id}`, type: '4.5T', model: '现代4.5T普货',
|
||||
color: '白色', location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市',
|
||||
status: 'Operating', operationStatus: '1', ownership: 'Self', rentCompany: '',
|
||||
contractNo: '合同甲', customerName: '测试客户公司', orgName: null, departmentName: '部门甲',
|
||||
subjectOrg: '公司甲', projectName: null, customerManager: '负责人甲', managerId: 'manager-a',
|
||||
brandLabel: '现代', ...overrides,
|
||||
});
|
||||
|
||||
test('duration buckets use exact approval-time boundaries and reject missing or future dates', () => {
|
||||
const since = (days: number) => new Date(NOW - days * 86400000).toISOString();
|
||||
for (const [days, expected] of [[0, '0-3d'], [2.999, '0-3d'], [3, '3-7d'], [6.999, '3-7d'], [7, '7-15d'], [14.999, '7-15d'], [15, '15-30d'], [29.999, '15-30d'], [30, '30d+'], [90, '30d+']] as const) {
|
||||
assert.equal(abnormalAgeBucket(since(days), NOW), expected);
|
||||
}
|
||||
for (const invalid of [undefined, '', 'not-a-date', since(-1)]) {
|
||||
assert.equal(abnormalAgeBucket(invalid, NOW), 'unknown');
|
||||
assert.equal(abnormalElapsedDays(invalid, NOW), null);
|
||||
}
|
||||
assert.equal(abnormalElapsedDays('2026-09-27T12:00:00+08:00', NOW), 1);
|
||||
assert.equal(abnormalAgeBucket('2026-09-25T04:00:00Z', NOW), '3-7d');
|
||||
});
|
||||
|
||||
test('asset dispositions reconcile to total while abnormal and pending stay inventory subsets', () => {
|
||||
const data = [
|
||||
make('leased'), make('self', { operationStatus: '2' }),
|
||||
make('available', { operationStatus: '3', status: 'Inventory' }),
|
||||
make('abnormal', { operationStatus: '3', status: 'Abnormal' }),
|
||||
make('pending', { operationStatus: '4', status: 'Pending' }),
|
||||
make('unknown', { operationStatus: null, status: 'Inventory' }),
|
||||
];
|
||||
const result = buildAssetAnalysis(data, approvals, NOW);
|
||||
assert.equal(result.summary.totalAssets, 6);
|
||||
assert.equal(result.summary.operating.total, 2);
|
||||
assert.equal(result.summary.operating.leased, 1);
|
||||
assert.equal(result.summary.operating.self, 1);
|
||||
assert.deepEqual(result.summary.inventory, { total: 3, inStock: 1, abnormal: 1 });
|
||||
assert.equal(result.summary.pendingDelivery, 1);
|
||||
assert.equal(result.summary.otherAssets, 1);
|
||||
assert.equal(result.cells.reduce((sum, row) => sum + row.quantity, 0), 6);
|
||||
const count = (metric: 'assets' | 'operating' | 'inventory' | 'abnormal' | 'pending') => result.cells.filter(cell => matchesAnalysisMetric(cell, metric)).reduce((sum, row) => sum + row.quantity, 0);
|
||||
assert.deepEqual(['assets', 'operating', 'inventory', 'abnormal', 'pending'].map(metric => count(metric as Parameters<typeof count>[0])), [6, 2, 3, 1, 1]);
|
||||
assert.equal(analysisCellForVehicle(data[0], approvals, NOW).assetDisposition, '租赁');
|
||||
assert.equal(analysisCellForVehicle(data[1], approvals, NOW).assetDisposition, '自营');
|
||||
assert.equal(analysisCellForVehicle(data[2], approvals, NOW).assetDisposition, '在库');
|
||||
assert.equal(analysisCellForVehicle(data[5], approvals, NOW).assetDisposition, '其他');
|
||||
});
|
||||
|
||||
test('one grouping keeps municipality, cold-chain and other-type detail filters consistent', () => {
|
||||
const first = make('1', { province: '上海市', city: '松江区', model: '现代4.5T冷链' });
|
||||
const second = make('2', { province: '上海市', city: '浦东新区', model: '现代4.5T冷链' });
|
||||
const result = buildAssetAnalysis([first, second, make('3', { type: '挂车', model: '挂车' })], approvals, NOW);
|
||||
const cold = result.cells.find(cell => cell.type === '4.5T冷链')!;
|
||||
assert.equal(cold.quantity, 2);
|
||||
assert.equal(cold.city, '上海市');
|
||||
assert.equal(result.cells.find(cell => cell.type === '其他')?.quantity, 1);
|
||||
assert.equal(matchesAnalysisFilters(cold, { region: '华东', city: '上海市', type: '4.5T冷链' }), true);
|
||||
assert.equal(matchesAnalysisFilters(cold, { region: '华南', city: '上海市' }), false);
|
||||
});
|
||||
|
||||
test('analysis regions and cities exactly match inventory statistics and retain unmapped locations', async (t) => {
|
||||
const stock = (id: string, province: string | null, city: string | null) => make(id, {
|
||||
operationStatus: '3', status: 'Inventory', province, city,
|
||||
// This legacy field intentionally disagrees with coordinates: neither API may use it.
|
||||
location: '广东', region: '广东',
|
||||
});
|
||||
const vehicles = [
|
||||
stock('east-1', '上海市', '松江区'), stock('east-2', '上海市', '浦东新区'),
|
||||
stock('south', '广东省', '广州市'), stock('northwest', '新疆', '乌鲁木齐市'),
|
||||
stock('southwest', '四川省', '成都市'), stock('north', '北京市', '朝阳区'),
|
||||
stock('central', '湖北省', '武汉市'), stock('unmapped', null, null),
|
||||
];
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
|
||||
const inventoryResponse = await app.request('/inventory-stats?subject=公司甲');
|
||||
const analysisResponse = await app.request('/analysis?subject=公司甲');
|
||||
assert.equal(inventoryResponse.status, 200);
|
||||
assert.equal(analysisResponse.status, 200);
|
||||
const inventory = await inventoryResponse.json() as Array<{
|
||||
region: string; city: string; brand: string; type: string; model: string; quantity: number;
|
||||
}>;
|
||||
const analysis = await analysisResponse.json() as AssetAnalysisResponse;
|
||||
const locations = (rows: Array<{ region: string; city: string; quantity: number }>) => rows
|
||||
.map(row => ({ region: row.region, city: row.city, quantity: row.quantity }))
|
||||
.sort((a, b) => `${a.region}:${a.city}`.localeCompare(`${b.region}:${b.city}`));
|
||||
assert.deepEqual(locations(analysis.cells), locations(inventory));
|
||||
assert.equal(analysis.summary.inventory.total, vehicles.length);
|
||||
for (const name of ['华东', '华南', '西北', '西南']) assert.ok(analysis.cells.some(cell => cell.region === name));
|
||||
// The inventory mapper also supports these regions. Do not arbitrarily reassign or discard them.
|
||||
assert.ok(analysis.cells.some(cell => cell.region === '华北'));
|
||||
assert.ok(analysis.cells.some(cell => cell.region === '华中'));
|
||||
const unknown = analysis.cells.find(cell => cell.region === '其他');
|
||||
assert.equal(unknown?.quantity, 1);
|
||||
assert.equal(unknown?.city, '其他');
|
||||
for (const row of inventory) {
|
||||
const key = JSON.stringify([row.region, row.city, row.brand, row.type, row.model]);
|
||||
const scopedResponse = await app.request('/analysis?subject=公司甲', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventoryKeys: [key] }),
|
||||
});
|
||||
assert.equal(scopedResponse.status, 200);
|
||||
const scoped = await scopedResponse.json() as AssetAnalysisResponse;
|
||||
assert.equal(scoped.summary.inventory.total, row.quantity);
|
||||
assert.ok(scoped.cells.every(cell => cell.region === row.region && cell.city === row.city));
|
||||
const details = await app.request('/analysis/details?subject=公司甲', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metric: 'inventory', inventoryKeys: [key], filters: { region: row.region, city: row.city } }),
|
||||
});
|
||||
assert.equal(details.status, 200);
|
||||
assert.equal((await details.json() as unknown[]).length, row.quantity);
|
||||
}
|
||||
});
|
||||
|
||||
test('abnormal classification never substitutes target or actual completion for approval time', () => {
|
||||
const vehicle = make('1', {
|
||||
operationStatus: '3', status: 'Abnormal', abnormalMove: {
|
||||
id: 'move-1', type: '维修', work: 'PRIVATE-WORK', targetCompletionTime: '2026-09-01 12:00',
|
||||
actualCompletionTime: '2026-09-27 12:00', initiatingDepartment: null, executingDepartment: null,
|
||||
responsiblePerson: null, driver: null, status: '待结束',
|
||||
},
|
||||
});
|
||||
assert.equal(analysisCellForVehicle(vehicle, approvals, NOW).abnormalAge, 'unknown');
|
||||
const history = { available: true, byMoveId: new Map([['move-1', '2026-09-21T12:00:00+08:00']]) };
|
||||
assert.equal(analysisCellForVehicle(vehicle, history, NOW).abnormalAge, '7-15d');
|
||||
const noWorkflow = buildAssetAnalysis([vehicle], { available: false, byMoveId: new Map() }, NOW);
|
||||
assert.equal(noWorkflow.approvalTimeAvailable, false);
|
||||
assert.equal(noWorkflow.summary.inventory.abnormal, 1);
|
||||
});
|
||||
|
||||
test('scope and dimensional filter validation rejects unknown keys, arrays and malformed enums', () => {
|
||||
assert.equal(validAnalysisScope({ inventoryKeys: [], subject: null, attendance: 'idle', isColdChain: 'false' }), true);
|
||||
assert.equal(validAnalysisFilters({ region: '华东', assetDisposition: '在库', abnormalAge: 'unknown' }), true);
|
||||
for (const bad of [null, [], 'all', { managerId: 'other' }, { inventoryKeys: [1] }, { inventoryKeys: null }, { category: 'anything' }, { attendance: 'all' }, { isTrailer: false }, { isColdChain: 'yes' }, { subject: 4 }]) {
|
||||
assert.equal(validAnalysisScope(bad), false, JSON.stringify(bad));
|
||||
}
|
||||
for (const bad of [null, [], { quantity: 10 }, { region: ['华东'] }, { unknown: 'value' }]) {
|
||||
assert.equal(validAnalysisFilters(bad), false, JSON.stringify(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('multi-value filters validate known dimensions and string arrays without changing single-value filters', () => {
|
||||
for (const valid of [{}, { region: [] }, { region: ['华东', '华南'], type: ['4.5T普货', '18T'] }]) {
|
||||
assert.equal(validAnalysisFilterValues(valid), true);
|
||||
}
|
||||
for (const invalid of [undefined, null, [], 'all', { region: '华东' }, { region: [1] }, { region: ['华东', null] }, { region: [['华东']] }, { region: null }, { region: undefined }, { notADimension: [] }]) {
|
||||
assert.equal(validAnalysisFilterValues(invalid), false, JSON.stringify(invalid));
|
||||
}
|
||||
assert.equal(validAnalysisFilters({ region: ['华东', '华南'] }), false);
|
||||
});
|
||||
|
||||
test('multi-value alternatives are ORed inside a dimension and ANDed with row filters', () => {
|
||||
const cell = analysisCellForVehicle(make('1'), approvals, NOW);
|
||||
assert.equal(matchesAnalysisFilterValues(cell, {}), true);
|
||||
assert.equal(matchesAnalysisFilterValues(cell, { region: [], type: [] }), true);
|
||||
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华东', '华南'], type: ['18T', '4.5T普货'] }), true);
|
||||
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华东', '华南'], type: ['18T'] }), false);
|
||||
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华南'], type: ['4.5T普货'] }), false);
|
||||
const multiMatches = matchesAnalysisFilterValues(cell, { region: ['华东', '华南'] });
|
||||
assert.equal(multiMatches && matchesAnalysisFilters(cell, { region: '华东' }), true);
|
||||
assert.equal(multiMatches && matchesAnalysisFilters(cell, { region: '华南' }), false);
|
||||
});
|
||||
|
||||
test('detail multi-select preserves permissions, company, scope and a clicked row intersection', async (t) => {
|
||||
const vehicles = [
|
||||
make('east-light'), make('east-heavy', { type: '18T', model: '飞驰18T' }),
|
||||
make('south-light', { province: '广东省', city: '广州市' }),
|
||||
make('south-heavy', { province: '广东省', city: '广州市', type: '18T', model: '飞驰18T' }),
|
||||
make('hidden-user', { managerId: 'manager-b' }),
|
||||
make('hidden-company', { subjectOrg: '公司乙' }),
|
||||
make('hidden-department', { departmentName: '部门乙' }),
|
||||
make('inventory', { operationStatus: '3', status: 'Inventory' }),
|
||||
];
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
|
||||
const secured = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
secured.use('*', async (c, next) => {
|
||||
c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel: 'personal', roles: [] });
|
||||
await next();
|
||||
});
|
||||
secured.route('/', app);
|
||||
const request = (extra: Record<string, unknown>) => secured.request('/analysis/details?subject=公司甲', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metric: 'operating', scope: { department: '部门甲' }, filters: {}, ...extra }),
|
||||
});
|
||||
const ids = async (extra: Record<string, unknown>) => {
|
||||
const response = await request(extra);
|
||||
assert.equal(response.status, 200);
|
||||
return (await response.json() as Array<{ id: string }>).map(row => row.id);
|
||||
};
|
||||
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'], type: ['4.5T普货', '18T'] } }), ['east-light', 'east-heavy', 'south-light', 'south-heavy']);
|
||||
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'], type: ['4.5T普货'] }, filters: { region: '华东' } }), ['east-light']);
|
||||
assert.deepEqual(await ids({ filterValues: { region: ['华南'] }, filters: { region: '华东' } }), []);
|
||||
assert.deepEqual(await ids({ filterValues: { region: [], type: [] }, filters: { type: '18T' } }), ['east-heavy', 'south-heavy']);
|
||||
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'] }, scope: { subject: '公司乙' } }), []);
|
||||
for (const invalid of [null, [], { region: '华东' }, { region: [1] }, { managerId: ['manager-b'] }]) {
|
||||
assert.equal((await request({ filterValues: invalid })).status, 400);
|
||||
}
|
||||
assert.equal((await request({ filters: { region: ['华东'] } })).status, 400);
|
||||
});
|
||||
|
||||
test('analysis and final details intersect company, permissions and exact entry scope without leaking row fields', async (t) => {
|
||||
const vehicles = [make('visible'), make('hidden', { managerId: 'manager-b' }), make('other-company', { subjectOrg: '公司乙' })];
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
|
||||
const secured = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
secured.use('*', async (c, next) => {
|
||||
c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel: 'personal', roles: [] });
|
||||
await next();
|
||||
});
|
||||
secured.route('/', app);
|
||||
const post = (path: string, body: unknown) => secured.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const response = await post('/analysis?subject=公司甲', { scope: { model: '现代4.5T普货' } });
|
||||
assert.equal(response.status, 200);
|
||||
const aggregate = await response.json() as AssetAnalysisResponse;
|
||||
assert.equal(aggregate.summary.totalAssets, 1);
|
||||
const serialized = JSON.stringify(aggregate);
|
||||
for (const secret of ['PRIVATE-VIN', 'TEST-visible', 'manager-a', 'manager-b', '测试客户公司', 'plateNumber', 'managerId', 'contractNo']) {
|
||||
assert.equal(serialized.includes(secret), false, `aggregate leaked ${secret}`);
|
||||
}
|
||||
const details = await post('/analysis/details?subject=公司甲', { metric: 'operating', filters: { region: '华东' }, generatedAt: aggregate.generatedAt, scope: { model: '现代4.5T普货' } });
|
||||
assert.equal(details.status, 200);
|
||||
assert.deepEqual((await details.json() as Array<{ id: string }>).map(row => row.id), ['visible']);
|
||||
const excluded = await post('/analysis?subject=公司甲', { scope: { subject: '公司乙' } });
|
||||
assert.equal((await excluded.json() as AssetAnalysisResponse).summary.totalAssets, 0);
|
||||
});
|
||||
|
||||
test('analysis inherits legacy list filtering including attendance and empty inventory scopes', async (t) => {
|
||||
const rows = [
|
||||
make('1'), make('2', { model: '现代4.5T冷链' }),
|
||||
make('3', { operationStatus: '3', status: 'Inventory' }),
|
||||
make('4', { type: '挂车', model: '挂车' }),
|
||||
];
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => rows);
|
||||
t.mock.method(vehicleRepository, 'getAttendanceMileageRows', async () => [
|
||||
{ plate_number: 'TEST-1', day_mileage: 10 }, { plate_number: 'TEST-2', day_mileage: 0 },
|
||||
]);
|
||||
const scopes: AssetAnalysisScope[] = [
|
||||
{ source: 'region', location: '华东' }, { vehicleType: '4.5T冷链', category: 'Operating' },
|
||||
{ department: '部门甲', manager: '负责人甲', attendance: 'active' },
|
||||
{ isTrailer: 'true' }, { isColdChain: 'false', brand: '现代', batch: '合同甲' },
|
||||
{ subject: ' 公司甲 ' }, { inventoryKeys: [] },
|
||||
];
|
||||
for (const scope of scopes) {
|
||||
const { inventoryKeys, ...params } = scope;
|
||||
const query = new URLSearchParams(Object.entries(params).filter((pair): pair is [string, string] => typeof pair[1] === 'string'));
|
||||
const listResponse = await app.request(`/list?${query}`, inventoryKeys ? {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inventoryKeys }),
|
||||
} : undefined);
|
||||
const analysisResponse = await app.request('/analysis', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }),
|
||||
});
|
||||
assert.equal(analysisResponse.status, 200);
|
||||
assert.equal((await analysisResponse.json() as AssetAnalysisResponse).summary.totalAssets, (await listResponse.json() as unknown[]).length, JSON.stringify(scope));
|
||||
}
|
||||
for (const body of [{ scope: { inventoryKeys: [42] } }, { scope: { unauthorizedField: 'x' } }, { inventoryKeys: 'all' }]) {
|
||||
assert.equal((await app.request('/analysis', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).status, 400);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { queryMovementApprovalTimes } from './repository.js';
|
||||
import type { Vehicle } from '../../types.js';
|
||||
import {
|
||||
ASSET_ANALYSIS_DIMENSIONS,
|
||||
type AbnormalAgeBucket,
|
||||
type AssetAnalysisCell,
|
||||
type AssetAnalysisFilters,
|
||||
type AssetAnalysisFilterValues,
|
||||
type AssetAnalysisMetric,
|
||||
type AssetAnalysisResponse,
|
||||
type AssetAnalysisScope,
|
||||
} from '../../../shared/assets/analytics.js';
|
||||
import {
|
||||
isInventoryAbnormal, isInventoryPending, isInventoryVehicle, isOperatingVehicle,
|
||||
mapMacroRegion, resolveCity,
|
||||
} from './model.js';
|
||||
|
||||
export interface ApprovalTimes {
|
||||
available: boolean;
|
||||
byMoveId: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified workflow source: movement = 异动审批; business_id is the movement id.
|
||||
* Only a PASS task reaching an end node in a finished instance counts as approval.
|
||||
* flow_his_task.update_time is documented as 审批完成时间. Movement start/create
|
||||
* timestamps are intentionally never substituted when workflow history is absent.
|
||||
*/
|
||||
export async function loadMovementApprovalTimes(vehicles: Vehicle[]): Promise<ApprovalTimes> {
|
||||
const ids = [...new Set(vehicles.filter(isInventoryAbnormal).flatMap(v => v.abnormalMove?.id ? [v.abnormalMove.id] : []))];
|
||||
if (!ids.length) return { available: true, byMoveId: new Map() };
|
||||
try {
|
||||
const rows = await queryMovementApprovalTimes(ids);
|
||||
return {
|
||||
available: true,
|
||||
byMoveId: new Map((rows as Array<{ move_id: string; approved_at: string | null }> )
|
||||
.filter(row => row.approved_at).map(row => [String(row.move_id), row.approved_at!])),
|
||||
};
|
||||
} catch {
|
||||
// Workflow access is optional; totals remain usable and missing ages stay explicit.
|
||||
return { available: false, byMoveId: new Map() };
|
||||
}
|
||||
}
|
||||
|
||||
export function abnormalElapsedDays(approvedAt: string | undefined, now: number): number | null {
|
||||
const timestamp = approvedAt ? Date.parse(approvedAt) : NaN;
|
||||
return Number.isFinite(timestamp) && timestamp <= now ? (now - timestamp) / 86400000 : null;
|
||||
}
|
||||
|
||||
export function abnormalAgeBucket(approvedAt: string | undefined, now: number): AbnormalAgeBucket {
|
||||
const days = abnormalElapsedDays(approvedAt, now);
|
||||
if (days === null) return 'unknown';
|
||||
if (days < 3) return '0-3d';
|
||||
if (days < 7) return '3-7d';
|
||||
if (days < 15) return '7-15d';
|
||||
if (days < 30) return '15-30d';
|
||||
return '30d+';
|
||||
}
|
||||
|
||||
export function analysisCellForVehicle(vehicle: Vehicle, approvals: ApprovalTimes, now: number): AssetAnalysisCell {
|
||||
const operating = isOperatingVehicle(vehicle);
|
||||
const inventory = isInventoryVehicle(vehicle);
|
||||
const abnormal = isInventoryAbnormal(vehicle);
|
||||
const pending = isInventoryPending(vehicle);
|
||||
const operation = (vehicle.operationStatus || '').trim();
|
||||
const operationKind = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[operation] || operation || '未分类';
|
||||
const inventoryState = !inventory ? 'not-applicable' : abnormal ? 'abnormal' : pending ? 'pending'
|
||||
: operationKind === '可运营' ? 'available' : operationKind === '待运营' ? 'awaiting' : 'unclassified';
|
||||
return {
|
||||
type: vehicle.type === '4.5T' ? (vehicle.model.includes('冷链') ? '4.5T冷链' : '4.5T普货')
|
||||
: ['18T', '49T'].includes(vehicle.type) ? vehicle.type : '其他',
|
||||
model: vehicle.model || '未标注型号',
|
||||
// Match /inventory-stats inventoryDimensions exactly: the legacy vehicle.location
|
||||
// field uses a different, older region taxonomy and must not drive BI drill-down.
|
||||
// Keep the mapper's unknown bucket so missing locations never disappear.
|
||||
region: mapMacroRegion(vehicle.province, vehicle.city),
|
||||
city: resolveCity(vehicle.city, vehicle.province),
|
||||
assetStatus: operating ? 'operating' : inventory ? 'inventory' : 'other',
|
||||
assetDisposition: inventory ? '在库' : operating ? (operationKind === '租赁' ? '租赁' : operationKind === '自营' ? '自营' : '其他运营') : '其他',
|
||||
operationKind,
|
||||
inventoryState,
|
||||
abnormalType: abnormal ? (vehicle.abnormalMove?.type || '未提供') : '不适用',
|
||||
abnormalAge: abnormal ? abnormalAgeBucket(approvals.byMoveId.get(vehicle.abnormalMove?.id || ''), now) : 'unknown',
|
||||
department: vehicle.model === '公务车/挂靠车' ? '公务车' : vehicle.departmentName || '未分配部门',
|
||||
manager: vehicle.customerManager || '未分配',
|
||||
customer: vehicle.customerName || '未分配客户',
|
||||
quantity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesAnalysisMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean {
|
||||
if (metric === 'assets') return true;
|
||||
if (metric === 'operating') return cell.assetStatus === 'operating';
|
||||
if (metric === 'inventory') return cell.assetStatus === 'inventory';
|
||||
if (metric === 'abnormal') return cell.inventoryState === 'abnormal';
|
||||
return cell.inventoryState === 'pending';
|
||||
}
|
||||
|
||||
export function matchesAnalysisFilters(cell: AssetAnalysisCell, filters: AssetAnalysisFilters): boolean {
|
||||
return ASSET_ANALYSIS_DIMENSIONS.every(dimension => filters[dimension] === undefined || cell[dimension] === filters[dimension]);
|
||||
}
|
||||
|
||||
export function matchesAnalysisFilterValues(cell: AssetAnalysisCell, filterValues: AssetAnalysisFilterValues): boolean {
|
||||
return ASSET_ANALYSIS_DIMENSIONS.every(dimension => {
|
||||
const values = filterValues[dimension];
|
||||
return !values?.length || values.includes(cell[dimension]);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAssetAnalysis(vehicles: Vehicle[], approvals: ApprovalTimes, now: number): AssetAnalysisResponse {
|
||||
const groups = new Map<string, AssetAnalysisCell>();
|
||||
for (const vehicle of vehicles) {
|
||||
const cell = analysisCellForVehicle(vehicle, approvals, now);
|
||||
const key = JSON.stringify(ASSET_ANALYSIS_DIMENSIONS.map(dimension => cell[dimension]));
|
||||
const existing = groups.get(key);
|
||||
if (existing) existing.quantity += 1;
|
||||
else groups.set(key, cell);
|
||||
}
|
||||
const operating = vehicles.filter(isOperatingVehicle);
|
||||
const inventory = vehicles.filter(isInventoryVehicle);
|
||||
return {
|
||||
generatedAt: new Date(now).toISOString(),
|
||||
approvalTimeAvailable: approvals.available,
|
||||
summary: {
|
||||
totalAssets: vehicles.length,
|
||||
operating: {
|
||||
total: operating.length,
|
||||
self: operating.filter(v => ['2', '自营'].includes((v.operationStatus || '').trim())).length,
|
||||
leased: operating.filter(v => ['1', '租赁'].includes((v.operationStatus || '').trim())).length,
|
||||
public: operating.filter(v => v.ownership === 'Public').length,
|
||||
hanging: 0,
|
||||
},
|
||||
inventory: {
|
||||
total: inventory.length,
|
||||
inStock: inventory.filter(v => v.status === 'Inventory').length,
|
||||
abnormal: inventory.filter(isInventoryAbnormal).length,
|
||||
},
|
||||
pendingDelivery: inventory.filter(isInventoryPending).length,
|
||||
otherAssets: vehicles.length - operating.length - inventory.length,
|
||||
},
|
||||
cells: [...groups.values()].sort((a, b) => b.quantity - a.quantity),
|
||||
};
|
||||
}
|
||||
|
||||
export function validAnalysisFilters(value: unknown): value is AssetAnalysisFilters {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.entries(value).every(([key, item]) => ASSET_ANALYSIS_DIMENSIONS.includes(key as typeof ASSET_ANALYSIS_DIMENSIONS[number]) && typeof item === 'string');
|
||||
}
|
||||
|
||||
export function validAnalysisFilterValues(value: unknown): value is AssetAnalysisFilterValues {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.entries(value).every(([key, values]) =>
|
||||
ASSET_ANALYSIS_DIMENSIONS.includes(key as typeof ASSET_ANALYSIS_DIMENSIONS[number])
|
||||
&& Array.isArray(values) && values.every(item => typeof item === 'string'));
|
||||
}
|
||||
|
||||
export function validAnalysisScope(value: unknown): value is AssetAnalysisScope {
|
||||
const keys = new Set(['brand', 'batch', 'model', 'location', 'status', 'category', 'vehicleType', 'manager', 'customer', 'department', 'attendance', 'isColdChain', 'isTrailer', 'source', 'subject']);
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.entries(value).every(([key, item]) => {
|
||||
if (key === 'inventoryKeys') return Array.isArray(item) && item.every(entry => typeof entry === 'string');
|
||||
if (!keys.has(key)) return false;
|
||||
if (key === 'subject' && item === null) return true;
|
||||
if (typeof item !== 'string') return false;
|
||||
if (key === 'category') return ['Inventory', 'Abnormal', 'Operating', 'Pending'].includes(item);
|
||||
if (key === 'attendance') return ['active', 'idle'].includes(item);
|
||||
if (key === 'isColdChain' || key === 'isTrailer') return ['true', 'false'].includes(item);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,7 @@ test('keeps all vehicle GET endpoints in their established order', () => {
|
||||
'/dept-stats',
|
||||
'/region-stats',
|
||||
'/customer-stats',
|
||||
'/analysis',
|
||||
'/list',
|
||||
'/inventory-stats',
|
||||
'/weekly-detail',
|
||||
|
||||
@@ -458,3 +458,21 @@ export class VehicleRepository {
|
||||
}
|
||||
|
||||
export const vehicleRepository = new VehicleRepository(pool as unknown as Database);
|
||||
|
||||
/** Workflow approval timestamps for abnormal-movement analysis. */
|
||||
export async function queryMovementApprovalTimes(ids: string[]) {
|
||||
if (!ids.length) return [];
|
||||
const [rows] = await pool.query(`
|
||||
SELECT f.business_id AS move_id,
|
||||
DATE_FORMAT(MAX(h.update_time), '%Y-%m-%dT%H:%i:%s+08:00') AS approved_at
|
||||
FROM \`ry-workflow\`.flow_instance f
|
||||
JOIN \`ry-workflow\`.flow_definition d ON d.id = f.definition_id AND d.flow_code = 'movement'
|
||||
JOIN \`ry-workflow\`.flow_his_task h ON h.instance_id = f.id
|
||||
AND h.del_flag = '0' AND h.skip_type = 'PASS' AND h.flow_status IN ('pass', '2')
|
||||
JOIN \`ry-workflow\`.flow_node n ON n.definition_id = f.definition_id
|
||||
AND n.node_code = h.target_node_code AND n.node_type = 2 AND n.del_flag = '0'
|
||||
WHERE f.del_flag = '0' AND f.flow_status IN ('finish', '8')
|
||||
AND f.business_id IN (${ids.map(() => '?').join(',')})
|
||||
GROUP BY f.business_id`, ids);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ import {
|
||||
} from './utils.js';
|
||||
import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './repository.js';
|
||||
|
||||
import type { AssetAnalysisMetric, AssetAnalysisScope } from '../../../shared/assets/analytics.js';
|
||||
import {
|
||||
abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis, loadMovementApprovalTimes,
|
||||
matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric,
|
||||
validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope,
|
||||
} from './analytics.js';
|
||||
|
||||
export { mapRegion } from './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: '其他' };
|
||||
|
||||
Reference in New Issue
Block a user