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:
@@ -5,6 +5,7 @@ import { cors } from 'hono/cors';
|
||||
import authRouter from './auth/login.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import eleRouter from './routes/ele/routes.js';
|
||||
import { createPreviewRouter, createPreviewMiddleware } from './auth/preview/routes.js';
|
||||
import energyRouter from './routes/energy/index.js';
|
||||
import feedbackRouter from './routes/feedback/routes.js';
|
||||
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap/routes.js';
|
||||
@@ -27,6 +28,8 @@ export function createApp(): Hono {
|
||||
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
|
||||
app.route('/api/auth', authRouter);
|
||||
app.use('/api/*', authMiddleware);
|
||||
app.route('/api/permission-preview', createPreviewRouter());
|
||||
app.use('/api/*', createPreviewMiddleware());
|
||||
|
||||
app.route('/api/vehicles', vehiclesRouter);
|
||||
app.route('/api/asset-assistant', assetAssistantRouter);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import pool from '../db/mysql.js';
|
||||
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
|
||||
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
||||
import { identitySchema } from './preview/directory.js';
|
||||
import type { AuthUser, JwtPayload } from './types.js';
|
||||
import { resolvePermissionLevel } from '../../shared/auth/roles.js';
|
||||
import { authMode, verifyAuthToken } from './config.js';
|
||||
import { passwordRouter } from './password.js';
|
||||
import { jwtSecret, serverConfig } from '../config.js';
|
||||
@@ -44,20 +45,15 @@ app.get('/exchange', async (c) => {
|
||||
const roleNames = userInfo.roles.map(r => r.roleName);
|
||||
|
||||
// 确定权限级别
|
||||
let permissionLevel: PermissionLevel = 'personal';
|
||||
if (roleNames.some(r => FULL_ACCESS_ROLES.includes(r))) {
|
||||
permissionLevel = 'full';
|
||||
} else if (roleNames.some(r => DEPT_ACCESS_ROLES.includes(r))) {
|
||||
permissionLevel = 'department';
|
||||
}
|
||||
const permissionLevel = resolvePermissionLevel(roleNames);
|
||||
|
||||
// 查询 depCode 对应的部门名称
|
||||
let depName = '';
|
||||
if (userInfo.depCode) {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
|
||||
[userInfo.depCode]
|
||||
`SELECT dep_name FROM ${identitySchema()}.tab_department WHERE dep_code = ? AND org_id = ? AND is_deleted = 0 LIMIT 1`,
|
||||
[userInfo.depCode, userInfo.orgId]
|
||||
) as [{ dep_name: string }[], unknown];
|
||||
depName = rows[0]?.dep_name || '';
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { verifiedManagerIds } from './manager-identity.js';
|
||||
import { filterByPermission } from './permissions.js';
|
||||
import type { AuthUser } from './types.js';
|
||||
test('only a unique enabled identity mapping extends personal vehicle IDs; ambiguity fails closed', () => {
|
||||
assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: 'new' }]), ['old', 'new']);
|
||||
assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: null }]), ['old']);
|
||||
assert.deepEqual(verifiedManagerIds('old', []), []);
|
||||
assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'wrong', vehicleId: 'new' }]), []);
|
||||
assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: 'new' }, { legacyId: 'old', vehicleId: 'other' }]), []);
|
||||
const user: AuthUser = { userId: 'old', loginName: 'login', userName: '人', depCode: 'D', depName: '部门甲', permissionLevel: 'personal', roles: [], managerIds: ['old', 'new'] };
|
||||
const items = [{ managerId: 'new', department: '部门乙' }, { managerId: 'other', department: '部门甲' }];
|
||||
assert.deepEqual(filterByPermission(items, user), [items[0]]);
|
||||
assert.deepEqual(filterByPermission(items, { ...user, managerIds: [] }), []);
|
||||
assert.deepEqual(filterByPermission(items, { ...user, permissionLevel: 'department' }), [items[1]]);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import pool from '../db/mysql.js';
|
||||
|
||||
/** Match login identifiers only, never human names. Ambiguous or disabled identities fail closed. */
|
||||
export async function resolveVehicleManagerIds(userId: string, loginName: string): Promise<string[]> {
|
||||
const schema = process.env.BI_IDENTITY_SCHEMA || 'lingniu_prod';
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(schema)) throw new Error('Invalid identity schema');
|
||||
const [rows] = await pool.query<any[]>(`SELECT CAST(u.id AS CHAR) legacyId, CAST(n.user_id AS CHAR) vehicleId
|
||||
FROM \`${schema}\`.tab_user u
|
||||
LEFT JOIN \`ry-cloud\`.sys_user n ON BINARY n.user_name=BINARY u.login_name
|
||||
AND n.tenant_id=? AND n.del_flag='0' AND n.status='0'
|
||||
WHERE u.id=? AND BINARY u.login_name=BINARY ? AND u.enabled=1 AND u.is_deleted=0
|
||||
AND (SELECT COUNT(*) FROM \`${schema}\`.tab_user duplicate
|
||||
WHERE BINARY duplicate.login_name=BINARY u.login_name AND duplicate.enabled=1 AND duplicate.is_deleted=0)=1`,
|
||||
[process.env.BI_VEHICLE_IDENTITY_TENANT_ID || '000000', userId, loginName]);
|
||||
return verifiedManagerIds(userId, rows);
|
||||
}
|
||||
export function verifiedManagerIds(userId: string, rows: { legacyId: string; vehicleId: string | null }[]): string[] {
|
||||
if (rows.length !== 1 || rows[0].legacyId !== userId) return [];
|
||||
return [...new Set([userId, ...(rows[0].vehicleId ? [rows[0].vehicleId] : [])])];
|
||||
}
|
||||
@@ -20,6 +20,8 @@ test('固定密码模式默认关闭、失败限流、只读权限及换密失
|
||||
app.use('/api/*', authMiddleware);
|
||||
app.get('/api/data', c => c.json({ ok: true }));
|
||||
app.post('/api/data', c => c.json({ ok: true }));
|
||||
app.post('/api/vehicles/analysis', c => c.json({ ok: true }));
|
||||
app.post('/api/vehicles/analysis/details', c => c.json({ ok: true }));
|
||||
const login = (password: unknown) => app.request('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||
assert.deepEqual(await (await app.request('/api/auth/config')).json(), { mode: 'sso' });
|
||||
assert.equal((await login(process.env.BI_AUTH_PASSWORD)).status, 403);
|
||||
@@ -35,6 +37,16 @@ test('固定密码模式默认关闭、失败限流、只读权限及换密失
|
||||
assert.deepEqual(user.roles, ['BI-LEADER-ENERGY']);
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
assert.equal((await app.request('/api/data', { headers })).status, 200);
|
||||
for (const readOnly of ['1', '0']) {
|
||||
process.env.DB_READ_ONLY = readOnly;
|
||||
for (const path of ['/api/vehicles/analysis', '/api/vehicles/analysis/details']) {
|
||||
assert.equal((await app.request(path, { method: 'POST', headers })).status, 200);
|
||||
assert.equal((await app.request(path, { method: 'POST' })).status, 401);
|
||||
}
|
||||
for (const path of ['/api/vehicles/analysis/extra', '/api/permission-preview/start', '/api/permission-preview/stop']) {
|
||||
assert.equal((await app.request(path, { method: 'POST', headers })).status, 403);
|
||||
}
|
||||
}
|
||||
const payload = verifyAuthToken(token);
|
||||
assert.equal(Number(payload.exp) - Number(payload.iat), 8 * 60 * 60);
|
||||
process.env.DB_READ_ONLY = '0';
|
||||
|
||||
@@ -46,6 +46,6 @@ export function filterByPermission<T>(
|
||||
// personal: 仅看自己负责的车辆 (bd = userId)
|
||||
return items.filter(v => {
|
||||
const obj = v as any;
|
||||
return obj.managerId === user.userId;
|
||||
return (user.managerIds ?? [user.userId]).includes(obj.managerId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import pool from '../../db/mysql.js';
|
||||
import type { AuthUser, PermissionLevel } from '../types.js';
|
||||
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES, resolvePermissionLevel } from '../../../shared/auth/roles.js';
|
||||
|
||||
export type DirectoryUser = AuthUser & { orgId: string };
|
||||
export interface UserDirectory {
|
||||
get(userId: string): Promise<DirectoryUser | null>;
|
||||
search(orgId: string, query: string, level: PermissionLevel | undefined, page: number): Promise<{ users: AuthUser[]; page: number; hasMore: boolean }>;
|
||||
}
|
||||
// This is the same identity source as the existing jump-token login, not the vehicle-system account IDs.
|
||||
export function identitySchema() {
|
||||
const name = process.env.BI_IDENTITY_SCHEMA || 'lingniu_prod';
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(name)) throw new Error('Invalid identity schema');
|
||||
return `\`${name}\``;
|
||||
}
|
||||
const publicUser = (user: DirectoryUser): AuthUser => {
|
||||
const { orgId: _, ...identity } = user;
|
||||
return identity;
|
||||
};
|
||||
export { publicUser };
|
||||
async function readUsers(where: string, values: unknown[], limit: number, offset = 0): Promise<DirectoryUser[]> {
|
||||
const db = identitySchema();
|
||||
const [rows] = await pool.query<any[]>(`SELECT CAST(u.id AS CHAR) userId, u.user_name userName,
|
||||
u.login_name loginName, CAST(u.org_id AS CHAR) orgId, COALESCE(d.dep_code, '') depCode,
|
||||
COALESCE(d.dep_name, '') depName FROM ${db}.tab_user u
|
||||
LEFT JOIN ${db}.tab_department d ON d.id=u.dep_id AND d.is_deleted=0
|
||||
WHERE u.is_deleted=0 AND u.enabled=1 AND ${where}
|
||||
ORDER BY u.user_name, u.id LIMIT ? OFFSET ?`, [...values, limit, offset]);
|
||||
if (!rows.length) return [];
|
||||
const ids = rows.map(r => r.userId);
|
||||
const [roleRows] = await pool.query<any[]>(`SELECT CAST(ur.user_id AS CHAR) userId, r.role_name roleName
|
||||
FROM ${db}.tab_user_role ur JOIN ${db}.tab_role r ON r.id=ur.role_id AND r.is_deleted=0
|
||||
JOIN ${db}.tab_user u ON u.id=ur.user_id AND r.org_id=u.org_id
|
||||
WHERE ur.user_id IN (${ids.map(() => '?').join(',')})`, ids);
|
||||
return rows.map(row => {
|
||||
const roles = [...new Set<string>(roleRows.filter(r => r.userId === row.userId).map(r => r.roleName))].sort();
|
||||
return { ...row, roles, permissionLevel: resolvePermissionLevel(roles) };
|
||||
});
|
||||
}
|
||||
export const userDirectory: UserDirectory = {
|
||||
async get(id) { return (await readUsers('u.id=?', [id], 1))[0] || null; },
|
||||
async search(orgId, query, level, page) {
|
||||
const values: unknown[] = [orgId];
|
||||
let where = 'u.org_id=?';
|
||||
if (query) {
|
||||
// Escape LIKE wildcards; values are always parameters.
|
||||
const term = `%${query.replace(/[!%_]/g, '!$&')}%`;
|
||||
where += " AND (u.user_name LIKE ? ESCAPE '!' OR u.login_name LIKE ? ESCAPE '!' OR d.dep_name LIKE ? ESCAPE '!')";
|
||||
values.push(term, term, term);
|
||||
}
|
||||
const hasRole = (roles: string[]) => {
|
||||
values.push(...roles);
|
||||
return `EXISTS (SELECT 1 FROM ${identitySchema()}.tab_user_role ur JOIN ${identitySchema()}.tab_role r ON r.id=ur.role_id WHERE ur.user_id=u.id AND r.org_id=u.org_id AND r.is_deleted=0 AND r.role_name IN (${roles.map(() => '?').join(',')}))`;
|
||||
};
|
||||
if (level === 'full') where += ` AND ${hasRole(FULL_ACCESS_ROLES)}`;
|
||||
if (level === 'department') where += ` AND NOT ${hasRole(FULL_ACCESS_ROLES)} AND ${hasRole(DEPT_ACCESS_ROLES)}`;
|
||||
if (level === 'personal') where += ` AND NOT ${hasRole([...FULL_ACCESS_ROLES, ...DEPT_ACCESS_ROLES])}`;
|
||||
const users = await readUsers(where, values, 21, (page - 1) * 20);
|
||||
return { users: users.slice(0, 20).map(publicUser), page, hasMore: users.length > 20 };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { createPreviewService, type PreviewAudit } from './service.js';
|
||||
import { createPreviewMiddleware, createPreviewRouter } from './routes.js';
|
||||
import type { AuthUser } from '../types.js';
|
||||
import type { DirectoryUser } from './directory.js';
|
||||
import { resolvePermissionLevel } from '../../../shared/auth/roles.js';
|
||||
import { filterByPermission } from '../permissions.js';
|
||||
import { getSession } from '../../routes/asset-assistant/session.js';
|
||||
const identity = (id: string, level: 'full' | 'department' | 'personal' = 'personal'): DirectoryUser => ({
|
||||
userId: id, loginName: id, userName: id, orgId: 'org', depCode: 'D1', depName: '部门甲',
|
||||
roles: level === 'full' ? ['BI-Leader'] : level === 'department' ? ['BI-Leader-Dep'] : [], permissionLevel: level,
|
||||
});
|
||||
function setup() {
|
||||
const actor = identity('authorized', 'full');
|
||||
const users = new Map([actor, identity('personal'), identity('department', 'department'), identity('leader', 'full'), { ...identity('foreign'), orgId: 'another' }].map(u => [u.userId, u]));
|
||||
const allowed = new Set([actor.userId]);
|
||||
const events: PreviewAudit[] = [];
|
||||
let now = 1000;
|
||||
const service = createPreviewService({ managerIds: async id => [id], allowedIds: () => allowed, now: () => now,
|
||||
audit: async event => { events.push(event); },
|
||||
directory: { get: async id => users.get(id) || null, search: async (org, q, level, page) => ({ users: [...users.values()].filter(u => u.orgId === org && u.userName.includes(q) && (!level || level === u.permissionLevel)), page, hasMore: false }) },
|
||||
});
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', users.get(c.req.header('Test-Actor') || '') || identity('outsider')); await next(); });
|
||||
app.route('/api/permission-preview', createPreviewRouter(service));
|
||||
app.use('*', createPreviewMiddleware(service));
|
||||
app.all('*', c => c.json({ user: c.get('user'), visible: filterByPermission([
|
||||
{ id: 1, department: '部门甲', managerId: 'personal' }, { id: 2, department: '部门甲', managerId: 'colleague' }, { id: 3, department: '部门乙', managerId: 'other' },
|
||||
], c.get('user')) }));
|
||||
const request = (path: string, token?: string, method = 'GET', body?: unknown, as = actor.userId) => app.request(path, { method,
|
||||
headers: { 'Test-Actor': as, ...(token ? { 'X-BI-Preview': token } : {}), 'Content-Type': 'application/json' },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
return { actor, users, allowed, events, service, request, advance: () => { now += 30 * 60 * 1000; } };
|
||||
}
|
||||
test('login and preview share role precedence', () => {
|
||||
assert.equal(resolvePermissionLevel([]), 'personal');
|
||||
assert.equal(resolvePermissionLevel(['BI-Leader-Dep']), 'department');
|
||||
for (const role of ['BI-Leader', '所有权限', '数智中心']) assert.equal(resolvePermissionLevel(['BI-Leader-Dep', role]), 'full');
|
||||
});
|
||||
test('only explicitly authorized actors can search or start; local bypass and full role alone never grant preview', async () => {
|
||||
const s = setup();
|
||||
for (const user of ['outsider', 'leader', 'dev-local']) {
|
||||
assert.equal((await s.request('/api/permission-preview/context', undefined, 'GET', undefined, user)).status, 200);
|
||||
assert.equal((await (await s.request('/api/permission-preview/context', undefined, 'GET', undefined, user)).json()).canPreview, false);
|
||||
assert.equal((await s.request('/api/permission-preview/users', undefined, 'GET', undefined, user)).status, 403);
|
||||
assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'personal' }, user)).status, 403);
|
||||
}
|
||||
assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'foreign' })).status, 403);
|
||||
assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'personal', permissionLevel: 'full' })).status, 400);
|
||||
});
|
||||
test('the actual target identity is applied to list, aggregate and detail queries; actor rights are never unioned', async () => {
|
||||
const s = setup();
|
||||
for (const [id, expected] of [['personal', [1]], ['department', [1, 2]], ['leader', [1, 2, 3]]] as const) {
|
||||
const { previewToken } = await s.service.start(s.actor, id);
|
||||
for (const [path, method] of [['/api/vehicles/list', 'GET'], ['/api/vehicles/analysis', 'POST'], ['/api/vehicles/analysis/details', 'POST']]) {
|
||||
const response = await s.request(path, previewToken, method);
|
||||
assert.equal(response.status, 200);
|
||||
const data = await response.json();
|
||||
assert.deepEqual(data.visible.map((v: { id: number }) => v.id), expected);
|
||||
assert.equal(data.user.userId, id);
|
||||
assert.equal(data.user.preview.actorId, s.actor.userId);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
}
|
||||
}
|
||||
assert.equal((await (await s.request('/api/vehicles/list')).json()).user.userId, s.actor.userId);
|
||||
});
|
||||
test('user search returns no directory list without keywords, while partial keywords still match', async () => {
|
||||
const s = setup();
|
||||
for (const suffix of ['', '?q=%20%20', '?level=full']) {
|
||||
const res = await s.request(`/api/permission-preview/users${suffix}`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), { users: [], page: 1, hasMore: false });
|
||||
}
|
||||
const res = await s.request('/api/permission-preview/users?q=erson');
|
||||
assert.deepEqual((await res.json()).users.map((u: AuthUser) => u.userId), ['personal']);
|
||||
});
|
||||
test('tokens are actor-bound, revocable, expiring, and fail closed for deleted users or changed organizations', async () => {
|
||||
const s = setup();
|
||||
const { previewToken } = await s.service.start(s.actor, 'personal');
|
||||
assert.equal((await s.request('/api/vehicles/list', previewToken, 'GET', undefined, 'leader')).status, 403);
|
||||
assert.equal((await s.request('/api/vehicles/list', 'forged')).status, 410);
|
||||
s.allowed.clear();
|
||||
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403);
|
||||
s.allowed.add(s.actor.userId);
|
||||
s.users.get('personal')!.orgId = 'foreign';
|
||||
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403);
|
||||
s.users.get('personal')!.orgId = 'org';
|
||||
s.users.get('personal')!.permissionLevel = 'department';
|
||||
assert.equal((await (await s.request('/api/vehicles/list', previewToken)).json()).visible.length, 2);
|
||||
s.users.delete('personal');
|
||||
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403);
|
||||
s.advance();
|
||||
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 410);
|
||||
});
|
||||
test('all writes, side-effect GETs and unrecognized POSTs are blocked; audited read POSTs and isolated session cleanup work', async () => {
|
||||
const s = setup(), { previewToken } = await s.service.start(s.actor, 'personal');
|
||||
for (const [path, method] of [['/api/feedback/upload', 'POST'], ['/api/feedback/submit', 'POST'], ['/api/feedback/1', 'PATCH'], ['/api/ele/import', 'POST'], ['/api/scheduling/notify', 'POST'], ['/api/asset-assistant/feedback', 'POST'], ['/api/vehicles/refresh', 'GET'], ['/api/vehicles/debug', 'HEAD'], ['/api/new-write-route', 'POST']]) {
|
||||
const res = await s.request(path, previewToken, method);
|
||||
assert.equal(res.status, 403, path);
|
||||
if (method !== 'HEAD') assert.equal((await res.json()).code, 'PREVIEW_READ_ONLY');
|
||||
}
|
||||
assert.equal((await s.request('/api/asset-assistant/ask', previewToken, 'POST')).status, 200);
|
||||
assert.equal((await s.request('/api/asset-assistant/session/test', previewToken, 'DELETE')).status, 200);
|
||||
assert.ok(s.events.some(e => e.event === 'blocked-write'));
|
||||
assert.ok(s.events.every(e => !JSON.stringify(e).includes(previewToken)));
|
||||
await s.request('/api/permission-preview/stop', previewToken, 'POST');
|
||||
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 410);
|
||||
assert.ok(s.events.some(e => e.event === 'stop'));
|
||||
});
|
||||
test('AI sessions are isolated between real target, actor and every preview session', () => {
|
||||
const real = identity('target');
|
||||
const a = { ...real, preview: { actorId: 'actor-a', sessionId: 'one' } };
|
||||
const session = getSession(a, null);
|
||||
for (const user of [real, { ...a, preview: { actorId: 'actor-b', sessionId: 'one' } }, { ...a, preview: { actorId: 'actor-a', sessionId: 'two' } }])
|
||||
assert.throws(() => getSession(user, null, session.id), /会话已过期/);
|
||||
assert.equal(getSession(a, null, session.id).id, session.id);
|
||||
});
|
||||
test('audit failure never grants a session or returns target data', async () => {
|
||||
const actor = identity('actor');
|
||||
const service = createPreviewService({ managerIds: async id => [id], allowedIds: () => new Set(['actor']), directory: { get: async id => identity(id), search: async () => ({ users: [], page: 1, hasMore: false }) }, audit: async () => { throw new Error('disk unavailable'); } });
|
||||
await assert.rejects(service.start(actor, 'target'), /disk unavailable/);
|
||||
});
|
||||
|
||||
test('local preview requires both explicit dev switches, an authorized organization anchor, and never works in production', async () => {
|
||||
const previous = { NODE_ENV: process.env.NODE_ENV, DEV_BYPASS_AUTH: process.env.DEV_BYPASS_AUTH, DEV_PERMISSION_PREVIEW: process.env.DEV_PERMISSION_PREVIEW, DEV_PERMISSION_PREVIEW_USER_ID: process.env.DEV_PERMISSION_PREVIEW_USER_ID };
|
||||
try {
|
||||
const actor = identity('dev-local', 'full'), anchor = identity('anchor');
|
||||
const service = createPreviewService({ allowedIds: () => new Set(['anchor']), directory: { get: async id => id === 'anchor' ? anchor : identity(id), search: async () => ({ users: [], page: 1, hasMore: false }) }, audit: async () => {}, managerIds: async id => [id] });
|
||||
process.env.DEV_PERMISSION_PREVIEW_USER_ID = 'anchor';
|
||||
process.env.NODE_ENV = 'development'; process.env.DEV_BYPASS_AUTH = '1'; process.env.DEV_PERMISSION_PREVIEW = '0';
|
||||
assert.equal((await service.context(actor)).canPreview, false);
|
||||
process.env.DEV_PERMISSION_PREVIEW = '1';
|
||||
assert.equal((await service.context(actor)).canPreview, true);
|
||||
const session = await service.start(actor, 'target');
|
||||
assert.equal((await service.current(actor, session.previewToken)).user.userId, 'target');
|
||||
process.env.NODE_ENV = 'production';
|
||||
assert.equal((await service.context(actor)).canPreview, false);
|
||||
await assert.rejects(service.current(actor, session.previewToken), /没有权限/);
|
||||
process.env.NODE_ENV = 'development'; process.env.DEV_BYPASS_AUTH = '0';
|
||||
assert.equal((await service.context(actor)).canPreview, false);
|
||||
process.env.DEV_BYPASS_AUTH = '1'; process.env.DEV_PERMISSION_PREVIEW_USER_ID = 'unauthorized';
|
||||
assert.equal((await service.context(actor)).canPreview, false);
|
||||
} finally {
|
||||
for (const [key,value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
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 '../types.js';
|
||||
import assistantRouter from '../../routes/asset-assistant/index.js';
|
||||
import { registerFeedbackRoutes } from '../../routes/feedback/routes.js';
|
||||
import { registerEleRoutes } from '../../routes/ele/routes.js';
|
||||
|
||||
function previewApp() {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user', { userId: 'target', userName: 'Target', loginName: 'target', depCode: '', depName: '',
|
||||
roles: ['BI-Leader', 'BI-LEADER-ENERGY'], permissionLevel: 'full', preview: { actorId: 'actor', sessionId: 'preview' } });
|
||||
await next();
|
||||
});
|
||||
app.route('/assistant', assistantRouter);
|
||||
const feedbackRouter = new Hono();
|
||||
const eleRouter = new Hono();
|
||||
const ensureTable = async () => { assert.fail('preview must not initialize schema'); };
|
||||
registerFeedbackRoutes(feedbackRouter, { db: pool, ensureTable });
|
||||
registerEleRoutes(eleRouter, { db: pool, ensureTable });
|
||||
app.route('/feedback', feedbackRouter);
|
||||
app.route('/ele', eleRouter);
|
||||
return app;
|
||||
}
|
||||
const paths = ['/assistant/gaps', '/feedback/mine', '/feedback/list', '/ele/list', '/ele/batches', '/ele/aggregate'];
|
||||
|
||||
test('preview gaps, feedback and charge pages perform only SELECT without schema initialization or retention writes', async (t) => {
|
||||
const statements: { sql: string; values: unknown }[] = [];
|
||||
t.mock.method(pool, 'query', async (sql: string, values: unknown) => {
|
||||
statements.push({ sql, values });
|
||||
return [[], []];
|
||||
});
|
||||
const app = previewApp();
|
||||
for (const path of paths) assert.equal((await app.request(path)).status, 200, path);
|
||||
assert.ok(statements.length >= paths.length);
|
||||
assert.ok(statements.every(({ sql }) => /^\s*SELECT\b/.test(sql)));
|
||||
const gap = statements.find(({ sql }) => sql.includes('FROM bi_asset_ai_gaps'))!;
|
||||
assert.match(gap.sql, /last_seen >= DATE_SUB/);
|
||||
assert.deepEqual(gap.values, ['target']);
|
||||
const feedback = statements.find(({ sql }) => sql.includes('WHERE user_id = ?'))!;
|
||||
assert.deepEqual(feedback.values, ['target']);
|
||||
});
|
||||
|
||||
test('preview pages report missing storage as 503 without attempting to repair or initialize it', 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 = previewApp();
|
||||
for (const path of paths) {
|
||||
const response = await app.request(path);
|
||||
assert.equal(response.status, 503, path);
|
||||
const body = await response.json();
|
||||
assert.ok(body.error || body.message, path);
|
||||
}
|
||||
// Aggregate runs its two independent SELECT queries concurrently.
|
||||
assert.equal(statements.length, paths.length + 1);
|
||||
assert.ok(statements.every(sql => /^\s*SELECT\b/.test(sql)));
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Hono, type Context, type Next } from 'hono';
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
import type { AuthUser, PermissionLevel } from '../types.js';
|
||||
import { previewService, PreviewError, type createPreviewService } from './service.js';
|
||||
|
||||
type Service = ReturnType<typeof createPreviewService>;
|
||||
function failure(c: Context, error: unknown) {
|
||||
if (error instanceof PreviewError) return c.json({ error: error.message, code: error.code }, error.status);
|
||||
console.error('permission-preview unavailable');
|
||||
return c.json({ error: '权限预览暂不可用,请稍后重试', code: 'PREVIEW_UNAVAILABLE' }, 503);
|
||||
}
|
||||
export function createPreviewRouter(service: Service = previewService) {
|
||||
const router = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
router.use('*', bodyLimit({ maxSize: 2048 }));
|
||||
router.use('*', async (c, next) => {
|
||||
c.header('Cache-Control', 'no-store');
|
||||
if (!c.get('user')) return c.json({ error: '请先登录' }, 401);
|
||||
try { await next(); } catch (e) { return failure(c, e); }
|
||||
});
|
||||
router.get('/context', async c => c.json(await service.context(c.get('user'))));
|
||||
router.get('/users', async c => {
|
||||
const q = (c.req.query('q') || '').trim(), level = c.req.query('level') || undefined;
|
||||
const page = Number(c.req.query('page') || '1');
|
||||
if (q.length > 80 || !Number.isSafeInteger(page) || page < 1 || page > 10000 || (level && !['full', 'department', 'personal'].includes(level)))
|
||||
return c.json({ error: '搜索条件无效' }, 400);
|
||||
return c.json(await service.search(c.get('user'), q, level as PermissionLevel | undefined, page));
|
||||
});
|
||||
router.post('/start', async c => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.userId !== 'string' || !/^[\w-]{1,64}$/.test(body.userId) || Object.keys(body).some(key => key !== 'userId'))
|
||||
return c.json({ error: '请选择有效用户' }, 400);
|
||||
return c.json(await service.start(c.get('user'), body.userId));
|
||||
});
|
||||
router.get('/current', async c => {
|
||||
const result = await service.current(c.get('user'), c.req.header('X-BI-Preview') || '');
|
||||
return c.json({ user: result.user, expiresAt: result.expiresAt });
|
||||
});
|
||||
router.post('/stop', async c => {
|
||||
await service.stop(c.get('user'), c.req.header('X-BI-Preview') || '');
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
router.onError((error, c) => failure(c, error));
|
||||
return router;
|
||||
}
|
||||
|
||||
// These POSTs only query data (AI previews suppress gap recording). Everything else is denied by default.
|
||||
const READ_POSTS = new Set(['/api/vehicles/analysis', '/api/vehicles/analysis/details', '/api/asset-assistant/ask', '/api/asset-assistant/export']);
|
||||
export function createPreviewMiddleware(service: Service = previewService) {
|
||||
return async (c: Context, next: Next) => {
|
||||
const token = c.req.header('X-BI-Preview');
|
||||
if (!token) return next();
|
||||
const actor = c.get('user') as AuthUser | undefined;
|
||||
if (!actor) return c.json({ error: '请先登录' }, 401);
|
||||
c.header('Cache-Control', 'no-store');
|
||||
try {
|
||||
const session = await service.current(actor, token);
|
||||
const readOnly = (!['/api/vehicles/refresh', '/api/vehicles/debug'].includes(c.req.path) && ['GET', 'HEAD'].includes(c.req.method)) || (c.req.method === 'POST' && READ_POSTS.has(c.req.path))
|
||||
|| (c.req.method === 'DELETE' && /^\/api\/asset-assistant\/session\/[^/]+$/.test(c.req.path));
|
||||
await service.audit({ event: readOnly ? 'read' : 'blocked-write', actorId: actor.userId, targetId: session.user.userId,
|
||||
sessionId: session.sessionId, method: c.req.method, path: c.req.path, permissionLevel: session.user.permissionLevel });
|
||||
if (!readOnly) return c.json({ error: '权限预览为只读模式,请退出预览后操作', code: 'PREVIEW_READ_ONLY' }, 403);
|
||||
c.set('actor', actor);
|
||||
c.set('user', { ...session.user, preview: { actorId: actor.userId, sessionId: session.sessionId } } satisfies AuthUser);
|
||||
} catch (error) { return failure(c, error); }
|
||||
// Do not catch downstream failures as authentication errors.
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { resolveVehicleManagerIds } from '../manager-identity.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { appendFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import type { AuthUser } from '../types.js';
|
||||
import { publicUser, userDirectory, type UserDirectory, type DirectoryUser } from './directory.js';
|
||||
|
||||
const TTL = 30 * 60 * 1000;
|
||||
// Verified legacy SSO accounts; override with a comma-separated list of immutable user IDs.
|
||||
export const DEFAULT_PREVIEW_USER_IDS = [
|
||||
'1014807041625047040', // puhongxia (default local preview organization anchor)
|
||||
'1105261382487539712', // zhanglan
|
||||
'1116631120763437056', // shishengliang
|
||||
];
|
||||
export function isLocalPreviewEnabled(actor: AuthUser) {
|
||||
return actor?.userId === 'dev-local' && actor.loginName === 'dev-local'
|
||||
&& process.env.NODE_ENV !== 'production' && process.env.DEV_BYPASS_AUTH === '1' && process.env.DEV_PERMISSION_PREVIEW === '1';
|
||||
}
|
||||
export const configuredPreviewIds = () => new Set((process.env.BI_PERMISSION_PREVIEW_USER_IDS ?? DEFAULT_PREVIEW_USER_IDS.join(',')).split(',').map(s => s.trim()).filter(Boolean));
|
||||
export type PreviewAudit = { event: string; actorId: string; targetId?: string; sessionId?: string; method?: string; path?: string; permissionLevel?: string };
|
||||
export async function writePreviewAudit(event: PreviewAudit) {
|
||||
const path = resolve(process.env.BI_PERMISSION_PREVIEW_AUDIT_FILE || 'logs/permission-preview.jsonl');
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||
await appendFile(path, JSON.stringify({ time: new Date().toISOString(), ...event }) + '\n', { mode: 0o600 });
|
||||
}
|
||||
export class PreviewError extends Error {
|
||||
constructor(public code: string, message: string, public status: 400 | 403 | 410 | 503 = 403) { super(message); }
|
||||
}
|
||||
type Session = { actorId: string; targetId: string; id: string; expires: number };
|
||||
export function createPreviewService(options: { directory?: UserDirectory; allowedIds?: () => Set<string>; audit?: (event: PreviewAudit) => Promise<void>; now?: () => number; managerIds?: (userId: string, loginName: string) => Promise<string[]> } = {}) {
|
||||
const directory = options.directory || userDirectory;
|
||||
const managerIds = options.managerIds || resolveVehicleManagerIds;
|
||||
const allowedIds = options.allowedIds || configuredPreviewIds;
|
||||
const audit = options.audit || writePreviewAudit;
|
||||
const now = options.now || Date.now;
|
||||
const sessions = new Map<string, Session>();
|
||||
const clean = () => { for (const [token, s] of sessions) if (s.expires <= now()) sessions.delete(token); };
|
||||
async function authorized(actor: AuthUser): Promise<DirectoryUser> {
|
||||
if (isLocalPreviewEnabled(actor)) {
|
||||
const anchorId = process.env.DEV_PERMISSION_PREVIEW_USER_ID || DEFAULT_PREVIEW_USER_IDS[0];
|
||||
if (!allowedIds().has(anchorId)) throw new PreviewError('PREVIEW_REVOKED', '本地预览未配置有效授权范围');
|
||||
const anchor = await directory.get(anchorId);
|
||||
if (!anchor) throw new PreviewError('PREVIEW_REVOKED', '本地预览组织范围不可用');
|
||||
return anchor;
|
||||
}
|
||||
if (!actor || !allowedIds().has(actor.userId)) throw new PreviewError('PREVIEW_REVOKED', '当前账号没有权限视角预览授权');
|
||||
const current = await directory.get(actor.userId);
|
||||
if (!current || current.loginName !== actor.loginName) throw new PreviewError('PREVIEW_REVOKED', '预览授权账号已失效,请重新登录');
|
||||
return current;
|
||||
}
|
||||
async function targetFor(actor: DirectoryUser, targetId: string) {
|
||||
const target = await directory.get(targetId);
|
||||
if (!target || !actor.orgId || target.orgId !== actor.orgId) throw new PreviewError('PREVIEW_TARGET_UNAVAILABLE', '该用户已停用或不在可预览的组织内');
|
||||
return target.permissionLevel === 'personal' ? { ...target, managerIds: await managerIds(target.userId, target.loginName) } : target;
|
||||
}
|
||||
return {
|
||||
async context(actor: AuthUser) {
|
||||
if (!actor || (!isLocalPreviewEnabled(actor) && !allowedIds().has(actor.userId))) return { canPreview: false, actor };
|
||||
try { await authorized(actor); return { canPreview: true, actor }; }
|
||||
catch (error) { if (error instanceof PreviewError) return { canPreview: false, actor }; throw error; }
|
||||
},
|
||||
async search(actor: AuthUser, query: string, level: 'full' | 'department' | 'personal' | undefined, page: number) {
|
||||
const identity = await authorized(actor);
|
||||
const term = query.trim();
|
||||
if (!term) return { users: [], page: 1, hasMore: false };
|
||||
return directory.search(identity.orgId, term, level, page);
|
||||
},
|
||||
async start(actor: AuthUser, targetId: string) {
|
||||
const identity = await authorized(actor), target = await targetFor(identity, targetId);
|
||||
clean();
|
||||
if (sessions.size >= 1000) throw new PreviewError('PREVIEW_BUSY', '预览会话已满,请稍后重试', 503);
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const s = { actorId: actor.userId, targetId, id: randomBytes(12).toString('hex'), expires: now() + TTL };
|
||||
await audit({ event: 'start', actorId: actor.userId, targetId, sessionId: s.id, permissionLevel: target.permissionLevel });
|
||||
// Bound per-user sessions, preserving a small number of independent tabs.
|
||||
const own = [...sessions].filter(([, value]) => value.actorId === actor.userId);
|
||||
while (own.length >= 5) sessions.delete(own.shift()![0]);
|
||||
sessions.set(token, s);
|
||||
return { previewToken: token, user: publicUser(target), expiresAt: new Date(s.expires).toISOString() };
|
||||
},
|
||||
async current(actor: AuthUser, token: string) {
|
||||
const s = sessions.get(token);
|
||||
if (!s || s.expires <= now()) { sessions.delete(token); throw new PreviewError('PREVIEW_EXPIRED', '预览已过期,请重新选择用户', 410); }
|
||||
if (s.actorId !== actor.userId) throw new PreviewError('PREVIEW_INVALID', '预览会话与登录账号不匹配');
|
||||
const identity = await authorized(actor), target = await targetFor(identity, s.targetId);
|
||||
return { user: publicUser(target), expiresAt: new Date(s.expires).toISOString(), sessionId: s.id };
|
||||
},
|
||||
async stop(actor: AuthUser, token: string) {
|
||||
const s = sessions.get(token);
|
||||
if (s && s.actorId !== actor.userId) throw new PreviewError('PREVIEW_INVALID', '预览会话与登录账号不匹配');
|
||||
if (s) {
|
||||
sessions.delete(token);
|
||||
await audit({ event: 'stop', actorId: actor.userId, targetId: s.targetId, sessionId: s.id });
|
||||
}
|
||||
},
|
||||
audit,
|
||||
};
|
||||
}
|
||||
export const previewService = createPreviewService();
|
||||
@@ -1,6 +1,10 @@
|
||||
export type PermissionLevel = 'full' | 'department' | 'personal';
|
||||
|
||||
export interface AuthUser {
|
||||
/** Server-only preview identity; never accepted from client input. */
|
||||
preview?: { actorId: string; sessionId: string };
|
||||
/** Verified legacy/current vehicle identities, resolved only on the server. */
|
||||
managerIds?: string[];
|
||||
userId: string;
|
||||
userName: string;
|
||||
loginName: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Context, Next } from 'hono';
|
||||
import { isReadOnlyRequest } from './read-only-policy.js';
|
||||
import type { AuthUser } from '../auth/types.js';
|
||||
import { resolveVehicleManagerIds } from '../auth/manager-identity.js';
|
||||
import type { AuthUser, JwtPayload } from '../auth/types.js';
|
||||
import { authMode, verifyAuthToken } from '../auth/config.js';
|
||||
|
||||
|
||||
@@ -41,23 +42,25 @@ export async function authMiddleware(c: Context, next: Next) {
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
let payload: JwtPayload;
|
||||
try {
|
||||
const payload = verifyAuthToken(token);
|
||||
if (payload.authMethod === 'password' && !isReadOnlyRequest(c.req.method, path)) {
|
||||
return c.json({ error: 'Password account is read-only' }, 403);
|
||||
}
|
||||
const user: AuthUser = {
|
||||
userId: payload.userId,
|
||||
userName: payload.userName,
|
||||
loginName: payload.loginName,
|
||||
depCode: payload.depCode,
|
||||
depName: payload.depName,
|
||||
permissionLevel: payload.permissionLevel,
|
||||
roles: payload.roles ?? [],
|
||||
};
|
||||
c.set('user', user);
|
||||
return next();
|
||||
payload = verifyAuthToken(token);
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid or expired token' }, 401);
|
||||
}
|
||||
if (payload.authMethod === 'password' && !isReadOnlyRequest(c.req.method, path)) {
|
||||
return c.json({ error: 'Password account is read-only' }, 403);
|
||||
}
|
||||
const user: AuthUser = {
|
||||
userId: payload.userId, userName: payload.userName, loginName: payload.loginName,
|
||||
depCode: payload.depCode, depName: payload.depName,
|
||||
permissionLevel: payload.permissionLevel, roles: payload.roles ?? [],
|
||||
};
|
||||
try {
|
||||
if (user.permissionLevel === 'personal') user.managerIds = await resolveVehicleManagerIds(user.userId, user.loginName);
|
||||
} catch {
|
||||
return c.json({ error: '账号数据范围暂不可用,请稍后重试' }, 503);
|
||||
}
|
||||
c.set('user', user);
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Query POSTs must be explicitly audited; never allow an entire route prefix. */
|
||||
export function isReadOnlyRequest(method: string, path: string): boolean {
|
||||
return ['GET', 'HEAD', 'OPTIONS'].includes(method)
|
||||
// Vehicle aggregation and detail queries only read permission-filtered data.
|
||||
|| (method === 'POST' && ['/api/vehicles/analysis', '/api/vehicles/analysis/details'].includes(path))
|
||||
// Interprets a question and reads permission-filtered assets; no business writes.
|
||||
|| (method === 'POST' && ['/api/asset-assistant/ask', '/api/asset-assistant/export'].includes(path))
|
||||
// Session cleanup only removes process-local analysis state.
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only.js';
|
||||
import assistantRouter from '../routes/asset-assistant/index.js';
|
||||
import { createPreviewRouter } from '../auth/preview/routes.js';
|
||||
|
||||
test('read-only preview permits asset query POST while preserving authentication and write restrictions', async () => {
|
||||
const previous = process.env.DB_READ_ONLY;
|
||||
@@ -46,3 +47,22 @@ test('read-only preview blocks write handlers but permits reads; normal mode is
|
||||
else process.env.DB_READ_ONLY = previous;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('read-only mode permits preview session controls only through their authentication guard', async () => {
|
||||
const previous = process.env.DB_READ_ONLY;
|
||||
try {
|
||||
process.env.DB_READ_ONLY = '1';
|
||||
const app = new Hono();
|
||||
app.use('*', readOnlyMiddleware);
|
||||
app.route('/api/permission-preview', createPreviewRouter());
|
||||
for (const action of ['start', 'stop']) {
|
||||
assert.equal((await app.request(`/api/permission-preview/${action}`, { method: 'POST', body: '{}' })).status, 401);
|
||||
assert.equal((await app.request(`/api/permission-preview/${action}`, { method: 'PATCH' })).status, 403);
|
||||
}
|
||||
assert.equal((await app.request('/api/permission-preview/other', { method: 'POST' })).status, 403);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DB_READ_ONLY;
|
||||
else process.env.DB_READ_ONLY = previous;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { isReadOnlyRequest } from './read-only-policy.js';
|
||||
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
|
||||
if (process.env.DB_READ_ONLY === '1'
|
||||
&& context.req.path !== '/api/auth/password'
|
||||
// Preview controls only manage in-memory sessions; downstream auth still applies.
|
||||
&& !(context.req.method === 'POST' && ['/api/permission-preview/start', '/api/permission-preview/stop'].includes(context.req.path))
|
||||
&& !isReadOnlyRequest(context.req.method, context.req.path)) {
|
||||
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
|
||||
}
|
||||
|
||||
@@ -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