fix: restore post-v1.1.14 features while preserving asset updates
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was canceled

Undo the full-tree rollback in 6ea1b3d and restore the pre-rollback v1.2.0 code. Preserve the new asset flow rules, abnormal inventory separation, range drilldowns and date picker; adapt the regression test to the restored routes layout.
This commit is contained in:
shishengliang
2026-09-16 10:48:32 +08:00
parent 17b084ce62
commit c2f21a3a8a
211 changed files with 36165 additions and 4434 deletions
+19 -6
View File
@@ -1,16 +1,17 @@
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { readOnlyMiddleware } from './middleware/read-only.js';
import { cors } from 'hono/cors';
import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js';
import eleRouter from './routes/ele/index.js';
import { authMiddleware } from './middleware/auth.js';
import eleRouter from './routes/ele/routes.js';
import energyRouter from './routes/energy/index.js';
import feedbackRouter from './routes/feedback/index.js';
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js';
import feedbackRouter from './routes/feedback/routes.js';
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap/routes.js';
import mileageRouter from './routes/mileage/index.js';
import schedulingRouter from './routes/scheduling/index.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
import vehiclesRouter from './routes/vehicles.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap/routes.js';
import vehiclesRouter from './routes/vehicles/routes.js';
/**
* 创建 HTTP 应用,但不监听端口、不访问数据库,也不启动后台任务。
@@ -20,6 +21,7 @@ export function createApp(): Hono {
const app = new Hono();
app.use('/api/*', cors());
app.use('/api/*', readOnlyMiddleware);
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
app.route('/api/auth', authRouter);
@@ -39,6 +41,17 @@ export function createApp(): Hono {
time: new Date().toISOString(),
}));
// 未匹配的 /api/* 必须返回 JSON 404。若落到下面的静态回退,会返回
// 200 + index.html,让前端 res.ok 为真、再在 res.json() 处抛解析错误,
// 把"路由不存在"伪装成数据问题。
app.all('/api/*', (context) => context.json({ error: 'Not Found' }, 404));
// 兜底错误处理:统一返回 JSON,避免把栈信息或 HTML 暴露给调用方。
app.onError((error, context) => {
console.error(`[server] unhandled error on ${context.req.method} ${context.req.path}:`, error);
return context.json({ error: 'Internal Server Error' }, 500);
});
// 生产环境由同一进程托管 Vite 构建产物,并回退到单页应用入口。
app.use('/*', serveStatic({ root: './dist' }));
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
+32
View File
@@ -0,0 +1,32 @@
import { createHmac } from 'node:crypto';
import jwt from 'jsonwebtoken';
import type { JwtPayload } from './types.js';
import { authMode, jwtSecret } from '../config.js';
// 认证方式统一由 server/config.ts 读取与校验,这里只做转发,
// 让既有的 `from './config.js'` 引用保持有效。
export { authMode };
export function passwordConfig() {
const password = process.env.BI_AUTH_PASSWORD || '';
const secret = jwtSecret();
if (!password.trim() || secret.length < 32) {
throw new Error('Password authentication is not configured securely');
}
return {
password,
key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex'),
};
}
export function verifyAuthToken(token: string): JwtPayload {
const passwordMode = authMode() === 'password';
// 不再有公开兜底密钥:SSO 模式下 JWT_SECRET 缺失会在启动期被 assertRuntimeConfig 拦下。
const key = passwordMode ? passwordConfig().key : jwtSecret();
if (!key) throw new Error('JWT_SECRET is not configured');
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') {
throw new Error('Authentication mode changed');
}
return payload;
}
+9 -7
View File
@@ -1,22 +1,24 @@
import { Hono } from 'hono';
import jwt from 'jsonwebtoken';
import pool from '../db.js';
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 { authMode, verifyAuthToken } from './config.js';
import { passwordRouter } from './password.js';
import { jwtSecret, serverConfig } from '../config.js';
const app = new Hono();
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
app.route('/', passwordRouter());
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
app.get('/exchange', async (c) => {
if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403);
const jumpToken = c.req.query('jumpToken');
if (!jumpToken) return c.json({ error: 'Missing jumpToken' }, 400);
try {
const res = await fetch(
`${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}`
`${serverConfig.externalApiBase}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}`
);
const data = await res.json() as {
code: number;
@@ -73,7 +75,7 @@ app.get('/exchange', async (c) => {
roles: roleNames,
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
const token = jwt.sign(payload, jwtSecret(), { expiresIn: '8h' });
const authUser: AuthUser = { ...payload };
return c.json({ token, user: authUser });
@@ -90,7 +92,7 @@ app.get('/me', async (c) => {
return c.json({ error: 'No token' }, 401);
}
try {
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET) as JwtPayload;
const payload = verifyAuthToken(authHeader.slice(7));
return c.json(payload);
} catch {
return c.json({ error: 'Invalid token' }, 401);
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import jwt from 'jsonwebtoken';
import { passwordRouter } from './password.js';
import { authMiddleware } from '../middleware/auth.js';
import { readOnlyMiddleware } from '../middleware/read-only.js';
import { verifyAuthToken } from './config.js';
test('固定密码模式默认关闭、失败限流、只读权限及换密失效', async () => {
const previous = { ...process.env };
try {
delete process.env.BI_AUTH_MODE;
process.env.JWT_SECRET = 'test-signing-secret-with-at-least-32-characters';
process.env.BI_AUTH_PASSWORD = 'short';
process.env.DEV_BYPASS_AUTH = '0';
const app = new Hono();
app.use('/api/*', readOnlyMiddleware);
app.route('/api/auth', passwordRouter());
app.use('/api/*', authMiddleware);
app.get('/api/data', c => c.json({ ok: true }));
app.post('/api/data', 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);
const ssoToken = jwt.sign({ userId: 'sso-user', roles: [] }, process.env.JWT_SECRET);
assert.equal(verifyAuthToken(ssoToken).userId, 'sso-user');
process.env.BI_AUTH_MODE = 'password';
assert.throws(() => verifyAuthToken(ssoToken));
assert.equal((await login('wrong')).status, 401);
process.env.DB_READ_ONLY = '1';
const response = await login(process.env.BI_AUTH_PASSWORD);
assert.equal(response.status, 200);
const { token, user } = await response.json();
assert.deepEqual(user.roles, ['BI-LEADER-ENERGY']);
const headers = { Authorization: `Bearer ${token}` };
assert.equal((await app.request('/api/data', { headers })).status, 200);
const payload = verifyAuthToken(token);
assert.equal(Number(payload.exp) - Number(payload.iat), 8 * 60 * 60);
process.env.DB_READ_ONLY = '0';
assert.equal((await app.request('/api/data', { method: 'POST', headers })).status, 403);
assert.equal((await app.request('/api/data')).status, 401);
assert.throws(() => verifyAuthToken(jwt.sign({ authMethod: 'password' }, 'wrong-key')));
process.env.BI_AUTH_PASSWORD = 'a-different-password-123456';
assert.equal((await app.request('/api/data', { headers })).status, 401);
process.env.BI_AUTH_MODE = 'sso';
assert.equal((await app.request('/api/data', { headers })).status, 401);
process.env.BI_AUTH_MODE = 'password';
delete process.env.BI_AUTH_PASSWORD;
assert.equal((await login('')).status, 503);
process.env.BI_AUTH_PASSWORD = ' ';
assert.equal((await login(' ')).status, 503);
process.env.BI_AUTH_PASSWORD = 'a-different-password-123456';
for (let i = 0; i < 20; i++) await login('wrong');
assert.equal((await login(process.env.BI_AUTH_PASSWORD)).status, 429);
} finally {
for (const key of ['BI_AUTH_MODE', 'BI_AUTH_PASSWORD', 'JWT_SECRET', 'DEV_BYPASS_AUTH', 'DB_READ_ONLY']) {
if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key];
}
}
});
+38
View File
@@ -0,0 +1,38 @@
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { createHash, timingSafeEqual } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { authMode, passwordConfig } from './config.js';
export function passwordRouter() {
const app = new Hono();
app.use('/password', bodyLimit({ maxSize: 4096 }));
// One shared account: a bounded instance-wide budget cannot be bypassed by spoofing proxy headers.
let attempts = 0;
let resetAt = 0;
app.get('/config', c => {
c.header('Cache-Control', 'no-store');
try { return c.json({ mode: authMode() }); }
catch { return c.json({ message: '验证方式配置错误,请联系管理员' }, 503); }
});
app.post('/password', async c => {
c.header('Cache-Control', 'no-store');
try {
if (authMode() !== 'password') return c.json({ message: '未启用固定密码登录' }, 403);
const config = passwordConfig();
if (Date.now() >= resetAt) { attempts = 0; resetAt = Date.now() + 15 * 60_000; }
if (attempts >= 20) {
c.header('Retry-After', String(Math.ceil((resetAt - Date.now()) / 1000)));
return c.json({ message: '尝试过于频繁,请稍后再试' }, 429);
}
attempts++;
const body = await c.req.json().catch(() => null);
if (typeof body?.password !== 'string' || body.password.length > 1024) return c.json({ message: '密码错误' }, 401);
const digest = (s: string) => createHash('sha256').update(s).digest();
if (!timingSafeEqual(digest(body.password), digest(config.password))) return c.json({ message: '密码错误' }, 401);
const user = { userId: 'bi-password-viewer', userName: '看板访客', loginName: 'bi-password-viewer', depCode: '', depName: '', permissionLevel: 'full' as const, roles: ['BI-LEADER-ENERGY'], authMethod: 'password' as const };
return c.json({ token: jwt.sign(user, config.key, { expiresIn: '8h', algorithm: 'HS256' }), user });
} catch { return c.json({ message: '密码登录配置不可用,请联系管理员' }, 503); }
});
return app;
}
+1
View File
@@ -11,6 +11,7 @@ export interface AuthUser {
}
export interface JwtPayload {
authMethod?: 'sso' | 'password';
userId: string;
userName: string;
loginName: string;
+2 -1
View File
@@ -1,8 +1,9 @@
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import { ensureSchedulingTables } from './db/schema/scheduling.js';
import { startMileageBackgroundJobs } from './routes/mileage/index.js';
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
export function startBackgroundServices(): void {
if (process.env.DB_READ_ONLY === '1') return;
ensureSchedulingTables().catch((error) => {
console.error('scheduling bootstrap error:', error);
});
+65
View File
@@ -0,0 +1,65 @@
import dotenv from 'dotenv';
// ESM 的 import 会被提升到调用方语句之前执行,所以这里显式加载一次环境文件,
// 保证本模块下面的常量真的读到了 .env。dotenv 是幂等的(不覆盖已有变量),
// 各数据库模块仍保留自己的 dotenv 调用以规避模块初始化顺序问题。
dotenv.config();
function readNumber(value: string | undefined, fallback: number): number {
if (value === undefined || value.trim() === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
/** 认证方式:`sso` 走业务系统跳转换票,`password` 只用固定密码。 */
export type AuthMode = 'sso' | 'password';
export function authMode(): AuthMode {
const mode = process.env.BI_AUTH_MODE || 'sso';
if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE');
return mode;
}
export const serverConfig = {
port: readNumber(process.env.SERVER_PORT, 3001),
externalApiBase: process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com',
} as const;
/**
* JWT 签名密钥。
* 刻意不提供兜底值:历史上 `'ln-bi-default-secret'` 这类公开常量会让任何人
* 离线伪造全权限令牌。缺失或过短一律由 {@link assertRuntimeConfig} 拒绝启动。
*/
export function jwtSecret(): string {
return process.env.JWT_SECRET || '';
}
/** 生产只读预览:DB_READ_ONLY=1 时禁止业务写入且不启动后台任务。 */
export const isDbReadOnly = () => process.env.DB_READ_ONLY === '1';
/** 氢能查询只读:对氢能连接池启用 SELECT/SHOW/WITH/EXPLAIN 白名单。 */
export const isHydrogenReadOnly = () => process.env.HYDROGEN_DB_READ_ONLY === '1';
const MIN_SECRET_LENGTH = 32;
/**
* 启动期配置校验。在监听端口之前调用,让配置错误在启动时暴露而不是在第一次请求时。
* 只检查"缺失会导致安全问题"的项,不检查业务数据库等可选能力。
*/
export function assertRuntimeConfig(): void {
if (authMode() === 'sso') {
const secret = jwtSecret();
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`JWT_SECRET must be set to a random value of at least ${MIN_SECRET_LENGTH} characters `
+ '(SSO 模式下缺失会让任何人都能伪造令牌)',
);
}
}
if (authMode() === 'password') {
const password = (process.env.BI_AUTH_PASSWORD || '').trim();
if (!password) throw new Error('BI_AUTH_PASSWORD must be set when BI_AUTH_MODE=password');
if (jwtSecret().length < MIN_SECRET_LENGTH) {
throw new Error(`JWT_SECRET must be at least ${MIN_SECRET_LENGTH} characters`);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
test('只读模式允许查询语句', () => {
for (const sql of ['SELECT 1', ' SHOW TABLES', 'WITH rows AS (SELECT 1) SELECT * FROM rows', 'EXPLAIN SELECT 1']) {
assert.doesNotThrow(() => assertHydrogenReadOnlySql(sql, true));
}
});
test('只读模式拒绝数据库写入语句', () => {
for (const sql of ['INSERT INTO ledger VALUES (1)', 'UPDATE ledger SET value = 1', 'DELETE FROM ledger', 'ALTER TABLE ledger ADD value INT']) {
assert.throws(
() => assertHydrogenReadOnlySql(sql, true),
/HYDROGEN_DB_READ_ONLY blocks non-read SQL/,
);
}
});
+7
View File
@@ -0,0 +1,7 @@
const READ_ONLY_SQL = /^\s*(SELECT|SHOW|WITH|EXPLAIN)\b/i;
export function assertHydrogenReadOnlySql(statement: string, enabled: boolean) {
if (enabled && !READ_ONLY_SQL.test(statement)) {
throw new Error('HYDROGEN_DB_READ_ONLY blocks non-read SQL');
}
}
+29
View File
@@ -0,0 +1,29 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
dotenv.config();
const rawHydrogenPool = mysql.createPool({
// 氢能账本归属线上业务库。保留专用变量,便于未来拆分独立只读库;
// 未配置专用变量时,复用主业务库,避免部署环境误回退到历史数据库地址。
host: process.env.HYDROGEN_DB_HOST || process.env.DB_HOST,
port: Number(process.env.HYDROGEN_DB_PORT || process.env.DB_PORT) || 3306,
user: process.env.HYDROGEN_DB_USER || process.env.DB_USER,
password: process.env.HYDROGEN_DB_PASSWORD || process.env.DB_PASSWORD,
database: process.env.HYDROGEN_DB_NAME || process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
const rawQuery = rawHydrogenPool.query.bind(rawHydrogenPool) as typeof rawHydrogenPool.query;
const hydrogenPool = {
query: ((sql: unknown, values?: unknown) => {
const statement = typeof sql === 'string' ? sql : String((sql as { sql?: unknown })?.sql ?? '');
assertHydrogenReadOnlySql(statement, process.env.HYDROGEN_DB_READ_ONLY === '1');
return rawQuery(sql as never, values as never);
}) as typeof rawHydrogenPool.query,
};
export default hydrogenPool;
@@ -1,4 +1,5 @@
import pool from '../../db.js';
import pool from '../mysql.js';
import { ddlAllowed, logDdlSkipped } from './guard.js';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
@@ -44,6 +45,7 @@ CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
let ensured = false;
export async function ensureChargeRecordTable(): Promise<void> {
if (ensured) return;
if (!ddlAllowed()) return logDdlSkipped('bi_ele_charge_record');
await pool.query(CREATE_TABLE_SQL);
ensured = true;
}
+44
View File
@@ -0,0 +1,44 @@
import pool from '../mysql.js';
import { ddlAllowed, logDdlSkipped } from './guard.js';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS bi_user_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
module VARCHAR(64) NULL,
content TEXT NOT NULL,
contact VARCHAR(200) NULL,
screenshots JSON NULL,
user_id VARCHAR(64) NULL,
user_name VARCHAR(128) NULL,
user_agent VARCHAR(512) NULL,
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
reply_content TEXT NULL,
reply_user VARCHAR(128) NULL,
reply_at DATETIME NULL,
created_at DATETIME NOT NULL,
KEY idx_created_at (created_at),
KEY idx_type (type),
KEY idx_status (status),
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
let ensured = false;
export async function ensureFeedbackTable(): Promise<void> {
if (ensured) return;
if (!ddlAllowed()) return logDdlSkipped('bi_user_feedback');
await pool.query(CREATE_TABLE_SQL);
// 兼容旧表:补齐缺失列
for (const alter of [
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
]) {
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
}
ensured = true;
}
+16
View File
@@ -0,0 +1,16 @@
import { isDbReadOnly } from '../../config.js';
/**
* 运行时建表守卫。
*
* 只读预览(DB_READ_ONLY=1)下不允许任何 DDL。此前只读保护只按 HTTP 方法拦截,
* 而建表实际由 GET 处理器触发,于是"只读"预览仍可能执行 CREATE TABLE。
*/
export function ddlAllowed(): boolean {
return !isDbReadOnly();
}
/** 统一的跳过日志,避免每个 schema 模块各写一份。 */
export function logDdlSkipped(name: string): void {
console.log(`[schema] ${name} skipped (DB_READ_ONLY=1)`);
}
+34
View File
@@ -0,0 +1,34 @@
import pool from '../mysql.js';
import { ddlAllowed, logDdlSkipped } from './guard.js';
/** 归档快照表:跨 schema,需与主库账号权限一致。 */
export const DAILY_REPORT_TABLE = 'lingniu_prod.tab_mileage_daily_report';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS ${DAILY_REPORT_TABLE} (
id BIGINT NOT NULL AUTO_INCREMENT,
report_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ARCHIVED',
source VARCHAR(20) NOT NULL,
vehicle_count INT NOT NULL DEFAULT 0,
operating_count INT NOT NULL DEFAULT 0,
inventory_count INT NOT NULL DEFAULT 0,
daily_mileage DECIMAL(14,2) NOT NULL DEFAULT 0,
day_over_day_rate DECIMAL(8,2) NULL,
report_payload JSON NOT NULL,
generated_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_report_date (report_date),
KEY idx_generated_at (generated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='里程每日汇报归档快照'
`;
let ensured = false;
export async function ensureDailyReportTable(): Promise<void> {
if (ensured) return;
if (!ddlAllowed()) return logDdlSkipped('tab_mileage_daily_report');
await pool.query(CREATE_TABLE_SQL);
ensured = true;
}
@@ -1,4 +1,5 @@
import pool from '../../db.js';
import pool from '../mysql.js';
import { ddlAllowed, logDdlSkipped } from './guard.js';
const CREATE_NOTIFICATIONS_TABLE = `
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
@@ -24,6 +25,7 @@ CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
`;
export async function ensureSchedulingTables(): Promise<void> {
if (!ddlAllowed()) return logDdlSkipped('scheduling tables');
try {
await pool.query(CREATE_NOTIFICATIONS_TABLE);
console.log('[scheduling] notifications table ready');
-17
View File
@@ -1,17 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const hydrogenPool = mysql.createPool({
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
user: process.env.HYDROGEN_DB_USER || 'root',
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
export default hydrogenPool;
+6 -6
View File
@@ -1,15 +1,15 @@
import { serve } from '@hono/node-server';
import dotenv from 'dotenv';
import { createApp } from './app.js';
import { startBackgroundServices } from './bootstrap.js';
import { assertRuntimeConfig, serverConfig } from './config.js';
dotenv.config();
// 配置错误在启动期暴露,而不是等到第一次请求才 500。
assertRuntimeConfig();
const app = createApp();
const port = Number(process.env.SERVER_PORT) || 3001;
console.log(`Server starting on port ${port}...`);
console.log(`Server starting on port ${serverConfig.port}...`);
startBackgroundServices();
serve({ fetch: app.fetch, port }, () => {
console.log(`Server running at http://localhost:${port}`);
serve({ fetch: app.fetch, port: serverConfig.port }, () => {
console.log(`Server running at http://localhost:${serverConfig.port}`);
});
+21
View File
@@ -0,0 +1,21 @@
import dotenv from 'dotenv';
// Local credentials stay outside version control. Exported environment wins.
dotenv.config({ path: '.env.local' });
dotenv.config();
Object.assign(process.env, {
DB_READ_ONLY: '1',
HYDROGEN_DB_READ_ONLY: '1',
MILEAGE_REPORT_AUTO_ARCHIVE: '0',
DEV_BYPASS_AUTH: '1',
});
// Import after configuring the environment: pools/auth read it at module load.
const [{ serve }, { createApp }] = await Promise.all([
import('@hono/node-server'),
import('./app.js'),
]);
// Intentionally do not call bootstrap: no schema initialization or schedulers.
serve({ fetch: createApp().fetch, hostname: '127.0.0.1', port: 3001 }, () => {
console.log('Local read-only BI API: http://127.0.0.1:3001');
});
@@ -1,8 +1,7 @@
import type { Context, Next } from 'hono';
import jwt from 'jsonwebtoken';
import type { JwtPayload, AuthUser } from './types.js';
import type { AuthUser } from '../auth/types.js';
import { authMode, verifyAuthToken } from '../auth/config.js';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
const BYPASS_AUTH = false;
@@ -15,7 +14,7 @@ export async function authMiddleware(c: Context, next: Next) {
}
// 本地开发免登录开关:.env 里设 DEV_BYPASS_AUTH=1 启用
if (process.env.DEV_BYPASS_AUTH === '1') {
if (process.env.NODE_ENV !== 'production' && process.env.DEV_BYPASS_AUTH === '1' && authMode() === 'sso') {
const devUser: AuthUser = {
userId: 'dev-local',
userName: '本地开发',
@@ -42,7 +41,10 @@ export async function authMiddleware(c: Context, next: Next) {
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
const payload = verifyAuthToken(token);
if (payload.authMethod === 'password' && !['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
return c.json({ error: 'Password account is read-only' }, 403);
}
const user: AuthUser = {
userId: payload.userId,
userName: payload.userName,
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { readOnlyMiddleware } from './read-only.js';
test('read-only preview blocks write handlers but permits reads; normal mode is unchanged', async () => {
const previous = process.env.DB_READ_ONLY;
try {
let calls = 0;
const app = new Hono();
app.use('*', readOnlyMiddleware);
app.all('*', (c) => { calls++; return c.text('ok'); });
process.env.DB_READ_ONLY = '1';
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
assert.equal((await app.request('/api/example', { method })).status, 403);
}
assert.equal(calls, 0);
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
assert.equal((await app.request('/api/example', { method })).status, 200);
}
delete process.env.DB_READ_ONLY;
assert.equal((await app.request('/api/example', { method: 'POST' })).status, 200);
} finally {
if (previous === undefined) delete process.env.DB_READ_ONLY;
else process.env.DB_READ_ONLY = previous;
}
});
+11
View File
@@ -0,0 +1,11 @@
import type { MiddlewareHandler } from 'hono';
/** Guard preview write endpoints before auth/route handlers can perform work. */
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
if (process.env.DB_READ_ONLY === '1'
&& context.req.path !== '/api/auth/password'
&& !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) {
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
}
return next();
};
-17
View File
@@ -1,17 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const mileagePool = mysql.createPool({
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
export default mileagePool;
-355
View File
@@ -1,355 +0,0 @@
import { Hono } from 'hono';
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
import * as XLSX from 'xlsx';
import pool from '../../db.js';
import { ensureChargeRecordTable } from './migration.js';
const app = new Hono();
// 与 xlsx 列名对齐
const COL = {
orderNo: '订单编号',
stationNo: '电站编号',
stationName: '电站名称',
terminalName: '终端名称',
region: '所属大区',
city: '所属城市',
district: '市区名称',
operatingCompany:'运营公司',
stationType: '电站类型',
orderStatus: '订单状态',
chargeForm: '充电形式',
startTime: '充电开始时间',
endTime: '充电结束时间',
duration: '充电时长(分钟)',
kwh: '充电电量(度)',
eFee: '充电电费(元)',
serviceFee: '充电服务费(元)',
fee: '充电费用(元)',
plate: '车牌号',
judgedPlate: '判定车牌号',
vin: '车架号',
customerName: '真实姓名',
customerPhone: '手机号',
enterpriseName: '企业名称',
} as const;
function safeStr(v: unknown, max = 250): string | null {
if (v == null) return null;
const s = String(v).trim();
if (!s) return null;
return s.slice(0, max);
}
function safeNum(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function safeDt(v: unknown): string | null {
const s = safeStr(v);
if (!s) return null;
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
// 简单校验
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
}
function normalizePlate(p: unknown): string | null {
const s = safeStr(p, 32);
if (!s) return null;
// 去掉所有空白字符
const trimmed = s.replace(/\s+/g, '').toUpperCase();
return trimmed || null;
}
function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
// 寻找含"订单编号"和"车牌号"的那一行
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!Array.isArray(row)) continue;
const cells = row.map(c => (c == null ? '' : String(c)));
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
return { headerIdx: i, header: cells };
}
}
return null;
}
interface ParsedRow {
orderNo: string;
raw: Record<string, unknown>;
values: {
stationNo: string | null; stationName: string | null; terminalName: string | null;
region: string | null; city: string | null; district: string | null;
operatingCompany: string | null; stationType: string | null;
orderStatus: string | null; chargeForm: string | null;
startTime: string | null; endTime: string | null;
duration: number | null; kwh: number | null;
eFee: number | null; serviceFee: number | null; fee: number | null;
plate: string | null; judgedPlate: string | null; vin: string | null;
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
};
}
function parseSheet(buf: ArrayBuffer): ParsedRow[] {
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
if (!ws) return [];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
const found = findHeaderRow(rows as unknown[][]);
if (!found) return [];
const { headerIdx, header } = found;
const idx = (label: string) => header.indexOf(label);
const result: ParsedRow[] = [];
for (let r = headerIdx + 1; r < rows.length; r++) {
const row = rows[r];
if (!Array.isArray(row)) continue;
const orderNo = safeStr(row[idx(COL.orderNo)]);
if (!orderNo) continue;
const raw: Record<string, unknown> = {};
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
result.push({
orderNo,
raw,
values: {
stationNo: safeStr(row[idx(COL.stationNo)]),
stationName: safeStr(row[idx(COL.stationName)]),
terminalName: safeStr(row[idx(COL.terminalName)]),
region: safeStr(row[idx(COL.region)]),
city: safeStr(row[idx(COL.city)]),
district: safeStr(row[idx(COL.district)]),
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
stationType: safeStr(row[idx(COL.stationType)]),
orderStatus: safeStr(row[idx(COL.orderStatus)]),
chargeForm: safeStr(row[idx(COL.chargeForm)]),
startTime: safeDt(row[idx(COL.startTime)]),
endTime: safeDt(row[idx(COL.endTime)]),
duration: safeNum(row[idx(COL.duration)]),
kwh: safeNum(row[idx(COL.kwh)]),
eFee: safeNum(row[idx(COL.eFee)]),
serviceFee: safeNum(row[idx(COL.serviceFee)]),
fee: safeNum(row[idx(COL.fee)]),
plate: normalizePlate(row[idx(COL.plate)]),
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
vin: safeStr(row[idx(COL.vin)]),
customerName: safeStr(row[idx(COL.customerName)]),
customerPhone: safeStr(row[idx(COL.customerPhone)]),
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
},
});
}
return result;
}
async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string>> {
if (plates.size === 0) return new Map();
const arr = Array.from(plates);
const placeholders = arr.map(() => '?').join(',');
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM vehicle_info
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
arr,
);
const map = new Map<string, string>();
for (const r of rows) {
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
}
return map;
}
// =========================================================
// POST /api/ele/import — 上传 xlsx 文件
// =========================================================
app.post('/import', async (c) => {
await ensureChargeRecordTable();
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const filename = file.name || 'unnamed.xlsx';
const buf = await file.arrayBuffer();
let parsed: ParsedRow[];
try {
parsed = parseSheet(buf);
} catch (e) {
console.error('parseSheet error:', e);
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
}
if (parsed.length === 0) {
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
}
// 文件内去重
const dedupMap = new Map<string, ParsedRow>();
for (const p of parsed) dedupMap.set(p.orderNo, p);
const records = Array.from(dedupMap.values());
const fileDuplicates = parsed.length - records.length;
// 系统车辆匹配
const allPlates = new Set<string>();
for (const r of records) {
if (r.values.plate) allPlates.add(r.values.plate);
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
}
const plateMap = await buildPlateLookup(allPlates);
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const importedAt = new Date();
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
const sql = `INSERT IGNORE INTO bi_ele_charge_record
(order_no, station_no, station_name, terminal_name, region, city, district,
operating_company, station_type, order_status, charge_form,
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
matched_truck_id, matched_plate, vehicle_kind, raw_json,
batch_id, imported_at)
VALUES ?`;
const values = records.map(r => {
const plate = r.values.plate || r.values.judgedPlate;
const matchedId = plate ? plateMap.get(plate) || null : null;
// 命中系统车辆=internal;其余(含车牌为空)一律 external
const kind = matchedId ? 'internal' : 'external';
return [
r.orderNo,
r.values.stationNo, r.values.stationName, r.values.terminalName,
r.values.region, r.values.city, r.values.district,
r.values.operatingCompany, r.values.stationType,
r.values.orderStatus, r.values.chargeForm,
r.values.startTime, r.values.endTime, r.values.duration,
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
r.values.plate, r.values.judgedPlate, r.values.vin,
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
matchedId, matchedId ? plate : null, kind,
JSON.stringify(r.raw),
batchId, importedAt,
];
});
const [result] = await pool.query<ResultSetHeader>(sql, [values]);
const inserted = result.affectedRows;
const dbDuplicates = records.length - inserted;
// 统计内/外(无车牌也算外部)
let internal = 0, external = 0;
for (const r of records) {
const plate = r.values.plate || r.values.judgedPlate;
if (plate && plateMap.has(plate)) internal++;
else external++;
}
return c.json({
ok: true,
filename,
batchId,
parsed: parsed.length,
fileDuplicates,
inserted,
dbDuplicates,
breakdown: { internal, external },
});
});
// =========================================================
// GET /api/ele/list — 分页列表(最新优先)
// =========================================================
app.get('/list', async (c) => {
await ensureChargeRecordTable();
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 kind = c.req.query('kind') || '';
const batchId = c.req.query('batchId') || '';
const search = c.req.query('search') || '';
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
if (kind === 'internal' || kind === 'external') {
where.push('vehicle_kind = ?');
params.push(kind);
}
if (batchId) {
where.push('batch_id = ?');
params.push(batchId);
}
if (search) {
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
const q = `%${search}%`;
params.push(q, q, q);
}
const offset = (page - 1) * limit;
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, order_no, station_name, terminal_name, region, city,
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
plate, judged_plate, customer_name, vehicle_kind,
batch_id, imported_at
FROM bi_ele_charge_record
WHERE ${where.join(' AND ')}
ORDER BY start_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
);
const [countRows] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${where.join(' AND ')}`,
params,
);
const total = Number(countRows[0]?.total || 0);
return c.json({ items: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
});
// =========================================================
// GET /api/ele/batches — 批次列表
// =========================================================
app.get('/batches', async (c) => {
await ensureChargeRecordTable();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT batch_id,
MIN(imported_at) AS imported_at,
COUNT(*) AS records,
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY batch_id
ORDER BY imported_at DESC
LIMIT 50`,
);
return c.json({ items: rows });
});
// =========================================================
// GET /api/ele/aggregate — 聚合统计
// =========================================================
app.get('/aggregate', async (c) => {
await ensureChargeRecordTable();
// 全量分类汇总
const [overallRows] = await pool.query<RowDataPacket[]>(
`SELECT vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY vehicle_kind`,
);
// 近 30 日按日
const [dailyRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
ORDER BY date DESC`,
);
return c.json({ overall: overallRows, daily: dailyRows });
});
export default app;
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildListFilter,
countVehicleKinds,
dedupeByOrderNo,
findHeaderRow,
normalizePlate,
safeDt,
safeNum,
safeStr,
toInsertValues,
type ParsedRow,
} from "./model.js";
function row(orderNo: string, plate: string | null): ParsedRow {
return {
orderNo,
raw: { 订单编号: orderNo },
values: {
stationNo: null, stationName: null, terminalName: null,
region: null, city: null, district: null,
operatingCompany: null, stationType: null,
orderStatus: null, chargeForm: null,
startTime: null, endTime: null,
duration: null, kwh: null,
eFee: null, serviceFee: null, fee: null,
plate, judgedPlate: null, vin: null,
customerName: null, customerPhone: null, enterpriseName: null,
},
};
}
test("取值清洗:字符串截断、数字与日期的非法值一律为空", () => {
assert.equal(safeStr(" x "), "x");
assert.equal(safeStr("abcdef", 3), "abc");
assert.equal(safeStr(" "), null);
assert.equal(safeNum("12.5"), 12.5);
assert.equal(safeNum("abc"), null);
assert.equal(safeNum(""), null);
// 日期:仅接受 YYYY-MM-DD[ HH:MM[:SS]],10/16 位自动补全
assert.equal(safeDt("2026-04-29"), "2026-04-29 00:00:00");
assert.equal(safeDt("2026-04-29 16:24"), "2026-04-29 16:24:00");
assert.equal(safeDt("2026-04-29 16:24:05"), "2026-04-29 16:24:05");
assert.equal(safeDt("29/04/2026"), null);
});
test("车牌归一化:去空白并大写,空值返回 null", () => {
assert.equal(normalizePlate(" 粤a 12345 "), "粤A12345");
assert.equal(normalizePlate(""), null);
assert.equal(normalizePlate(null), null);
});
test("表头定位:必须同时包含订单编号与车牌号", () => {
const rows = [
["报表说明", null],
["订单编号", "车牌号", "充电电量(度)"],
["A1", "粤A1", "10"],
];
assert.deepEqual(findHeaderRow(rows), {
headerIdx: 1,
header: ["订单编号", "车牌号", "充电电量(度)"],
});
assert.equal(findHeaderRow([["订单编号"]]), null);
});
test("文件内按订单号去重保留最后一条", () => {
const parsed = [row("A", "粤A"), row("B", "粤B"), row("A", "粤C")];
const { records, fileDuplicates } = dedupeByOrderNo(parsed);
assert.equal(fileDuplicates, 1);
assert.equal(records.length, 2);
assert.equal(records.find(r => r.orderNo === "A")?.values.plate, "粤C");
});
test("列表筛选:片段与参数顺序固定,非法 kind 被丢弃", () => {
assert.deepEqual(buildListFilter({}), { where: ["1=1"], params: [] });
assert.deepEqual(buildListFilter({ kind: "bogus" }), { where: ["1=1"], params: [] });
assert.deepEqual(
buildListFilter({ kind: "internal", batchId: "b1", search: "粤A" }),
{
where: ["1=1", "vehicle_kind = ?", "batch_id = ?", "(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)"],
params: ["internal", "b1", "%粤A%", "%粤A%", "%粤A%"],
},
);
});
test("插入值:命中系统车辆记 internal,其余记 external", () => {
const records = [row("A", "粤A1"), row("B", "粤B2"), row("C", null)];
const plateMap = new Map([["粤A1", "9"]]);
const values = toInsertValues(records, plateMap, "batch-1", new Date("2026-04-29T00:00:00Z"));
assert.equal(values.length, 3);
assert.equal(values[0].length, 30, "每行 30 列,顺序由契约测试锁定");
assert.equal(values[0][0], "A");
assert.equal(values[0][24], "9"); // matched_truck_id
assert.equal(values[0][25], "粤A1"); // matched_plate
assert.equal(values[0][26], "internal"); // vehicle_kind
assert.equal(values[0][28], "batch-1"); // batch_id
assert.equal(values[1][24], null);
assert.equal(values[1][26], "external");
assert.equal(values[2][26], "external", "无车牌也算外部");
assert.equal(values[2][25], null, "无车牌时不写 matched_plate");
assert.deepEqual(countVehicleKinds(records, plateMap), { internal: 1, external: 2 });
});
+220
View File
@@ -0,0 +1,220 @@
import * as XLSX from 'xlsx';
/**
* 电能导入的纯逻辑:xlsx 解析、取值清洗、筛选条件与插入值组装。
* 不访问数据库,因此可被 node:test 直接覆盖。
*/
/** 与 xlsx 列名对齐。 */
export const COL = {
orderNo: '订单编号',
stationNo: '电站编号',
stationName: '电站名称',
terminalName: '终端名称',
region: '所属大区',
city: '所属城市',
district: '市区名称',
operatingCompany:'运营公司',
stationType: '电站类型',
orderStatus: '订单状态',
chargeForm: '充电形式',
startTime: '充电开始时间',
endTime: '充电结束时间',
duration: '充电时长(分钟)',
kwh: '充电电量(度)',
eFee: '充电电费(元)',
serviceFee: '充电服务费(元)',
fee: '充电费用(元)',
plate: '车牌号',
judgedPlate: '判定车牌号',
vin: '车架号',
customerName: '真实姓名',
customerPhone: '手机号',
enterpriseName: '企业名称',
} as const;
export function safeStr(v: unknown, max = 250): string | null {
if (v == null) return null;
const s = String(v).trim();
if (!s) return null;
return s.slice(0, max);
}
export function safeNum(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
export function safeDt(v: unknown): string | null {
const s = safeStr(v);
if (!s) return null;
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
}
export function normalizePlate(p: unknown): string | null {
const s = safeStr(p, 32);
if (!s) return null;
// 去掉所有空白字符
const trimmed = s.replace(/\s+/g, '').toUpperCase();
return trimmed || null;
}
export interface ParsedRow {
orderNo: string;
raw: Record<string, unknown>;
values: {
stationNo: string | null; stationName: string | null; terminalName: string | null;
region: string | null; city: string | null; district: string | null;
operatingCompany: string | null; stationType: string | null;
orderStatus: string | null; chargeForm: string | null;
startTime: string | null; endTime: string | null;
duration: number | null; kwh: number | null;
eFee: number | null; serviceFee: number | null; fee: number | null;
plate: string | null; judgedPlate: string | null; vin: string | null;
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
};
}
export function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
// 寻找含"订单编号"和"车牌号"的那一行
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!Array.isArray(row)) continue;
const cells = row.map(c => (c == null ? '' : String(c)));
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
return { headerIdx: i, header: cells };
}
}
return null;
}
export function parseSheet(buf: ArrayBuffer): ParsedRow[] {
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
if (!ws) return [];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
const found = findHeaderRow(rows as unknown[][]);
if (!found) return [];
const { headerIdx, header } = found;
const idx = (label: string) => header.indexOf(label);
const result: ParsedRow[] = [];
for (let r = headerIdx + 1; r < rows.length; r++) {
const row = rows[r];
if (!Array.isArray(row)) continue;
const orderNo = safeStr(row[idx(COL.orderNo)]);
if (!orderNo) continue;
const raw: Record<string, unknown> = {};
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
result.push({
orderNo,
raw,
values: {
stationNo: safeStr(row[idx(COL.stationNo)]),
stationName: safeStr(row[idx(COL.stationName)]),
terminalName: safeStr(row[idx(COL.terminalName)]),
region: safeStr(row[idx(COL.region)]),
city: safeStr(row[idx(COL.city)]),
district: safeStr(row[idx(COL.district)]),
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
stationType: safeStr(row[idx(COL.stationType)]),
orderStatus: safeStr(row[idx(COL.orderStatus)]),
chargeForm: safeStr(row[idx(COL.chargeForm)]),
startTime: safeDt(row[idx(COL.startTime)]),
endTime: safeDt(row[idx(COL.endTime)]),
duration: safeNum(row[idx(COL.duration)]),
kwh: safeNum(row[idx(COL.kwh)]),
eFee: safeNum(row[idx(COL.eFee)]),
serviceFee: safeNum(row[idx(COL.serviceFee)]),
fee: safeNum(row[idx(COL.fee)]),
plate: normalizePlate(row[idx(COL.plate)]),
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
vin: safeStr(row[idx(COL.vin)]),
customerName: safeStr(row[idx(COL.customerName)]),
customerPhone: safeStr(row[idx(COL.customerPhone)]),
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
},
});
}
return result;
}
/** 文件内按订单号去重(保留最后一条,与既有行为一致)。 */
export function dedupeByOrderNo(parsed: ParsedRow[]): { records: ParsedRow[]; fileDuplicates: number } {
const dedupMap = new Map<string, ParsedRow>();
for (const p of parsed) dedupMap.set(p.orderNo, p);
const records = Array.from(dedupMap.values());
return { records, fileDuplicates: parsed.length - records.length };
}
/** 列表接口的 WHERE 片段与参数(顺序固定,由契约测试锁定)。 */
export function buildListFilter(query: { kind?: string; batchId?: string; search?: string }): {
where: string[];
params: (string | number)[];
} {
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
const kind = query.kind || '';
if (kind === 'internal' || kind === 'external') {
where.push('vehicle_kind = ?');
params.push(kind);
}
if (query.batchId) {
where.push('batch_id = ?');
params.push(query.batchId);
}
if (query.search) {
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
const q = `%${query.search}%`;
params.push(q, q, q);
}
return { where, params };
}
/**
* 组装批量插入的值。
* 命中系统车辆记 internal;其余(含车牌为空)一律 external。
*/
export function toInsertValues(
records: ParsedRow[],
plateMap: Map<string, string>,
batchId: string,
importedAt: Date,
): unknown[][] {
return records.map(r => {
const plate = r.values.plate || r.values.judgedPlate;
const matchedId = plate ? plateMap.get(plate) || null : null;
const kind = matchedId ? 'internal' : 'external';
return [
r.orderNo,
r.values.stationNo, r.values.stationName, r.values.terminalName,
r.values.region, r.values.city, r.values.district,
r.values.operatingCompany, r.values.stationType,
r.values.orderStatus, r.values.chargeForm,
r.values.startTime, r.values.endTime, r.values.duration,
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
r.values.plate, r.values.judgedPlate, r.values.vin,
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
matchedId, matchedId ? plate : null, kind,
JSON.stringify(r.raw),
batchId, importedAt,
];
});
}
/** 内/外部车辆计数:无车牌也算外部。 */
export function countVehicleKinds(
records: ParsedRow[],
plateMap: Map<string, string>,
): { internal: number; external: number } {
let internal = 0;
let external = 0;
for (const r of records) {
const plate = r.values.plate || r.values.judgedPlate;
if (plate && plateMap.has(plate)) internal++;
else external++;
}
return { internal, external };
}
+120
View File
@@ -0,0 +1,120 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
/**
* 电能充电记录表的全部 SQL 出口。
* 语句文本与参数顺序由 routes.test.ts 的契约测试锁定。
*/
/** 只需要 query 能力的最小依赖,便于测试注入 mock。 */
export interface Database {
query<T = any>(sql: string, values?: unknown[]): Promise<[T, ...any[]]>;
}
/** 按车牌查系统车辆 id(用于区分内部/外部车辆)。 */
export async function findTruckIdsByPlates(
db: Database,
plates: string[],
): Promise<Map<string, string>> {
if (plates.length === 0) return new Map();
const placeholders = plates.map(() => '?').join(',');
const [rows] = await db.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM vehicle_info
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
plates,
);
const map = new Map<string, string>();
for (const r of rows) {
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
}
return map;
}
/** 批量 INSERT IGNORE,靠 order_no 的 UNIQUE 约束做库内去重。 */
export async function insertChargeRecords(db: Database, values: unknown[][]): Promise<number> {
const [result] = await db.query<ResultSetHeader>(
`INSERT IGNORE INTO bi_ele_charge_record
(order_no, station_no, station_name, terminal_name, region, city, district,
operating_company, station_type, order_status, charge_form,
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
matched_truck_id, matched_plate, vehicle_kind, raw_json,
batch_id, imported_at)
VALUES ?`,
[values],
);
return result.affectedRows;
}
export async function listChargeRecords(
db: Database,
filter: { where: string[]; params: (string | number)[]; limit: number; offset: number },
): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT id, order_no, station_name, terminal_name, region, city,
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
plate, judged_plate, customer_name, vehicle_kind,
batch_id, imported_at
FROM bi_ele_charge_record
WHERE ${filter.where.join(' AND ')}
ORDER BY start_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...filter.params, filter.limit, filter.offset],
);
return rows;
}
export async function countChargeRecords(
db: Database,
filter: { where: string[]; params: (string | number)[] },
): Promise<number> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${filter.where.join(' AND ')}`,
filter.params,
);
return Number(rows[0]?.total || 0);
}
export async function listBatches(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT batch_id,
MIN(imported_at) AS imported_at,
COUNT(*) AS records,
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY batch_id
ORDER BY imported_at DESC
LIMIT 50`,
);
return rows;
}
export async function aggregateByVehicleKind(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY vehicle_kind`,
);
return rows;
}
export async function aggregateDaily30d(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
ORDER BY date DESC`,
);
return rows;
}
+209
View File
@@ -0,0 +1,209 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
import { Hono } from "hono";
import type { AuthUser } from "../../auth/types.js";
import { registerEleRoutes, type EleDependencies } from "./routes.js";
import type { Database } from "./repository.js";
import type { ParsedRow } from "./model.js";
interface Call {
sql: string;
params: unknown[];
}
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
const calls: Call[] = [];
const queue = [...resultSets];
const db: Database = {
async query(sql: string, params?: unknown[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return [queue.shift() ?? [], []] as never;
},
};
return { db, calls };
}
const ENERGY_USER: AuthUser = {
userId: "u-e",
userName: "能源",
loginName: "energy",
depCode: "",
depName: "",
permissionLevel: "full",
roles: ["BI-LEADER-ENERGY"],
};
const PLAIN_USER: AuthUser = { ...ENERGY_USER, roles: [] };
function makeApp(user: AuthUser | undefined, deps: EleDependencies): Hono<{ Variables: { user: AuthUser } }> {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
if (user) c.set("user", user);
await next();
});
registerEleRoutes(app as never, deps);
return app;
}
function parsedRow(orderNo: string, plate: string | null, kwh: number | null): ParsedRow {
return {
orderNo,
raw: { 订单编号: orderNo },
values: {
stationNo: "S1", stationName: "站", terminalName: null,
region: null, city: null, district: null,
operatingCompany: null, stationType: null,
orderStatus: null, chargeForm: null,
startTime: "2026-04-29 10:00:00", endTime: null,
duration: null, kwh,
eFee: null, serviceFee: null, fee: 12.5,
plate, judgedPlate: null, vin: null,
customerName: null, customerPhone: null, enterpriseName: null,
},
};
}
test("电能导入受能源角色保护(fail-closed)", async () => {
const { db, calls } = createMockDb();
const app = makeApp(PLAIN_USER, { db, ensureTable: async () => {} });
for (const [method, url] of [["GET", "/list"], ["GET", "/batches"], ["GET", "/aggregate"]] as const) {
const res = await app.request(url, { method });
assert.equal(res.status, 403, `${method} ${url} 应被拒绝`);
}
assert.equal(calls.length, 0, "无权限时不应触达数据库");
});
test("列表:WHERE 片段、分页参数与总数查询保持一致", async () => {
const { db, calls } = createMockDb([[{ id: 1 }], [{ total: 3 }]]);
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
const res = await app.request("/list?kind=internal&batchId=b1&search=粤A&page=2&limit=10");
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { items: [{ id: 1 }], total: 3, page: 2, limit: 10, totalPages: 1 });
assert.equal(calls.length, 2);
assert.equal(
calls[0].sql,
"SELECT id, order_no, station_name, terminal_name, region, city, start_time, end_time, duration_min, kwh, fee, e_fee, service_fee, plate, judged_plate, customer_name, vehicle_kind, batch_id, imported_at FROM bi_ele_charge_record WHERE 1=1 AND vehicle_kind = ? AND batch_id = ? AND (order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?) ORDER BY start_time DESC, id DESC LIMIT ? OFFSET ?",
);
assert.deepEqual(calls[0].params, ["internal", "b1", "%粤A%", "%粤A%", "%粤A%", 10, 10]);
assert.equal(
calls[1].sql,
"SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE 1=1 AND vehicle_kind = ? AND batch_id = ? AND (order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)",
);
assert.deepEqual(calls[1].params, ["internal", "b1", "%粤A%", "%粤A%", "%粤A%"], "计数不带分页参数");
});
test("列表:limit 上限 200,page 最小 1", async () => {
const { db, calls } = createMockDb([[], [{ total: 0 }]]);
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
await app.request("/list?limit=9999&page=0");
assert.deepEqual(calls[0].params, [200, 0], "limit 截断到 200,page 回退 1 => offset 0");
});
test("批次与聚合:SQL 原样保留", async () => {
const { db, calls } = createMockDb([[], [], []]);
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
await app.request("/batches");
assert.equal(
calls[0].sql,
"SELECT batch_id, MIN(imported_at) AS imported_at, COUNT(*) AS records, SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count, SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count, ROUND(SUM(kwh), 2) AS total_kwh, ROUND(SUM(fee), 2) AS total_fee FROM bi_ele_charge_record GROUP BY batch_id ORDER BY imported_at DESC LIMIT 50",
);
calls.length = 0;
await app.request("/aggregate");
assert.equal(calls.length, 2);
assert.equal(
calls[0].sql,
"SELECT vehicle_kind, COUNT(*) AS records, ROUND(SUM(kwh), 2) AS total_kwh, ROUND(SUM(fee), 2) AS total_fee FROM bi_ele_charge_record GROUP BY vehicle_kind",
);
assert.equal(
calls[1].sql,
"SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date, vehicle_kind, COUNT(*) AS records, ROUND(SUM(kwh), 2) AS total_kwh, ROUND(SUM(fee), 2) AS total_fee FROM bi_ele_charge_record WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind ORDER BY date DESC",
);
});
test("导入:车牌匹配 + 批量插入语句与统计口径不变", async () => {
const { db, calls } = createMockDb([
[{ plate_number: "粤A1", truck_id: 9 }], // findTruckIdsByPlates
{ affectedRows: 1 }, // insert
]);
const rows = [
parsedRow("A", "粤B2", 10),
parsedRow("B", "粤B2", 20),
parsedRow("A", "粤A1", 30), // 文件内重复订单号:按既有口径保留最后一条
];
const app = makeApp(ENERGY_USER, {
db,
ensureTable: async () => {},
parse: () => rows,
});
const form = new FormData();
form.set("file", new File([new Uint8Array([1])], "charges.xlsx"));
const res = await app.request("/import", { method: "POST", body: form });
assert.equal(res.status, 200);
const payload = await res.json() as Record<string, unknown>;
assert.equal(payload.ok, true);
assert.equal(payload.filename, "charges.xlsx");
assert.equal(payload.parsed, 3);
assert.equal(payload.fileDuplicates, 1);
assert.equal(payload.inserted, 1);
assert.equal(payload.dbDuplicates, 1, "去重后 2 条,插入 1 条 => 库内重复 1 条");
assert.deepEqual(payload.breakdown, { internal: 1, external: 1 });
assert.equal(typeof payload.batchId, "string");
assert.equal(calls.length, 2);
assert.equal(
calls[0].sql,
"SELECT plate_number, CAST(id AS CHAR) AS truck_id FROM vehicle_info WHERE del_flag = '0' AND plate_number IN (?,?)",
);
assert.deepEqual(calls[0].params, ["粤A1", "粤B2"]);
assert.equal(
calls[1].sql,
"INSERT IGNORE INTO bi_ele_charge_record (order_no, station_no, station_name, terminal_name, region, city, district, operating_company, station_type, order_status, charge_form, start_time, end_time, duration_min, kwh, e_fee, service_fee, fee, plate, judged_plate, vin, customer_name, customer_phone, enterprise_name, matched_truck_id, matched_plate, vehicle_kind, raw_json, batch_id, imported_at) VALUES ?",
);
const inserted = calls[1].params[0] as unknown[][];
assert.equal(inserted.length, 2);
assert.equal(inserted[0].length, 30);
assert.equal(inserted[0][24], "9", "truck id 以字符串写入,与原实现一致");
assert.equal(inserted[0][26], "internal");
assert.equal(inserted[1][26], "external");
});
test("导入:解析失败与空结果返回 400,且不写库", async () => {
const { db, calls } = createMockDb();
const app = makeApp(ENERGY_USER, {
db,
ensureTable: async () => {},
parse: () => { throw new Error('bad file'); },
});
const form = new FormData();
form.set("file", new File([new Uint8Array([1])], "bad.xlsx"));
const res = await app.request("/import", { method: "POST", body: form });
assert.equal(res.status, 400);
assert.equal(calls.length, 0);
const emptyApp = makeApp(ENERGY_USER, { db, ensureTable: async () => {}, parse: () => [] });
const form2 = new FormData();
form2.set("file", new File([new Uint8Array([1])], "empty.xlsx"));
const res2 = await emptyApp.request("/import", { method: "POST", body: form2 });
assert.equal(res2.status, 400);
assert.equal(calls.length, 0);
});
test("分层:路由文件不再直接书写 SQL", () => {
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
const offenders = source.match(/\b(SELECT|INSERT IGNORE|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts,路由只做校验与组装");
});
+139
View File
@@ -0,0 +1,139 @@
import { Hono } from 'hono';
import pool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { ensureChargeRecordTable } from '../../db/schema/ele.js';
import {
buildListFilter,
countVehicleKinds,
dedupeByOrderNo,
parseSheet,
toInsertValues,
type ParsedRow,
} from './model.js';
import {
aggregateByVehicleKind,
aggregateDaily30d,
countChargeRecords,
findTruckIdsByPlates,
insertChargeRecords,
listBatches,
listChargeRecords,
type Database,
} from './repository.js';
export interface EleDependencies {
db: Database;
/** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */
ensureTable: () => Promise<void>;
/** xlsx 解析,测试可注入以避免构造真实文件。 */
parse?: (buf: ArrayBuffer) => ParsedRow[];
}
export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
const { db, ensureTable } = deps;
const parse = deps.parse ?? parseSheet;
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。
app.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 电能导入需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
// POST /api/ele/import — 上传 xlsx 文件
app.post('/import', async (c) => {
await ensureTable();
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const filename = file.name || 'unnamed.xlsx';
const buf = await file.arrayBuffer();
let parsed: ParsedRow[];
try {
parsed = parse(buf);
} catch (e) {
console.error('parseSheet error:', e);
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
}
if (parsed.length === 0) {
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
}
const { records, fileDuplicates } = dedupeByOrderNo(parsed);
// 系统车辆匹配
const allPlates = new Set<string>();
for (const r of records) {
if (r.values.plate) allPlates.add(r.values.plate);
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
}
const plateMap = await findTruckIdsByPlates(db, Array.from(allPlates));
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const importedAt = new Date();
const inserted = await insertChargeRecords(db, toInsertValues(records, plateMap, batchId, importedAt));
const dbDuplicates = records.length - inserted;
const breakdown = countVehicleKinds(records, plateMap);
return c.json({
ok: true,
filename,
batchId,
parsed: parsed.length,
fileDuplicates,
inserted,
dbDuplicates,
breakdown,
});
});
// GET /api/ele/list — 分页列表(最新优先)
app.get('/list', async (c) => {
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({
kind: c.req.query('kind'),
batchId: c.req.query('batchId'),
search: c.req.query('search'),
});
const offset = (page - 1) * limit;
const items = await listChargeRecords(db, { ...filter, limit, offset });
const total = await countChargeRecords(db, filter);
return c.json({ items, total, page, limit, totalPages: Math.ceil(total / limit) });
});
// GET /api/ele/batches — 批次列表
app.get('/batches', async (c) => {
await ensureTable();
return c.json({ items: await listBatches(db) });
});
// GET /api/ele/aggregate — 聚合统计
app.get('/aggregate', async (c) => {
await ensureTable();
const [overall, daily] = await Promise.all([
aggregateByVehicleKind(db),
aggregateDaily30d(db),
]);
return c.json({ overall, daily });
});
}
/** 生产用路由器:绑定真实连接池与建表函数。 */
export function createEleRouter(): Hono {
const app = new Hono();
registerEleRoutes(app, { db: pool, ensureTable: ensureChargeRecordTable });
return app;
}
const app = createEleRouter();
export default app;
+44
View File
@@ -1,8 +1,52 @@
export const HYDROGEN_MIN_DATE = '2024-01-01';
// 核对功能上线期初(含当日):待核对口径的唯一起算日,前端文案同样引用该常量。
export { HYDROGEN_VERIFY_START_DATE } from '../../../shared/hydrogen-verify.js';
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
export const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
export const HYDROGEN_LOCAL = `refuel_time`;
export const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
export const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
// 充值记录与加氢事实共用台账。经营、日报和下钻必须显式排除充值,
// 充值模块则继续使用 BASE 条件读取自己的流水。
export const HYDROGEN_FUEL_ONLY_WHERE = `${HYDROGEN_BASE_WHERE} AND COALESCE(record_source, '') <> 'external_recharge_manual'`;
export const HYDROGEN_FUEL_ONLY_WHERE_B = `${HYDROGEN_BASE_WHERE_B} AND COALESCE(b.record_source, '') <> 'external_recharge_manual'`;
export const ELECTRIC_LOCAL = `charging_start_time`;
// ---------------------------------------------------------------------------
// 单站看板的 SQL 片段与租户常量。
// 这些是 SQL 的一部分,放在 constants.ts 而不是路由里,避免 SQL 拼装散落两处。
// ---------------------------------------------------------------------------
/** 租户只由服务端配置决定,不接受浏览器传值。 */
export const HYDROGEN_TENANT_ID = process.env.HYDROGEN_TENANT_ID?.trim() || '000000';
export const MANUAL_RECHARGE_SOURCE = 'external_recharge_manual';
export const NORMALIZED_PLATE = (column: string) =>
`UPPER(REPLACE(REPLACE(REPLACE(TRIM(${column}), ' ', ''), '.', ''), '·', ''))`;
export const NORMALIZED_PLATE_UNICODE = (column: string) =>
`CONVERT(${NORMALIZED_PLATE(column)} USING utf8mb4) COLLATE utf8mb4_unicode_ci`;
export const EXTERNAL_CUSTOMER_MAP_CTE = `
WITH external_vehicle_candidates AS (
SELECT ${NORMALIZED_PLATE_UNICODE('plate_number')} AS normalizedPlate,
MAX(NULLIF(TRIM(actual_user), '')) AS customerName,
COUNT(DISTINCT NULLIF(TRIM(actual_user), '')) AS customerCount
FROM hydrogen_order_transfer_external_vehicle_user
WHERE NULLIF(TRIM(plate_number), '') IS NOT NULL
GROUP BY ${NORMALIZED_PLATE_UNICODE('plate_number')}
), external_vehicle_map AS (
SELECT normalizedPlate, customerName
FROM external_vehicle_candidates
WHERE customerCount = 1
AND customerName IS NOT NULL
)`;
// ---------------------------------------------------------------------------
// 氢能 v2 承担方映射片段(b.settlement_type:1 客户 / 2 我司 / 其余“其他”)。
// 与站点区域回退表达式一样,这些片段被多条查询共享,集中放置避免各处复制。
// ---------------------------------------------------------------------------
export const CUSTOMER_BEARING_ORDER = "b.settlement_type = 1";
export const COMPANY_BEARING_ORDER = "b.settlement_type = 2";
export const OTHER_BEARING_ORDER =
"(b.settlement_type NOT IN (1, 2) OR b.settlement_type IS NULL)";
+14 -52
View File
@@ -1,7 +1,13 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type pool from '../../db.js';
import type pool from '../../db/mysql.js';
import type { cached } from './cache.js';
import {
loadElectricCurrentMonthTrend,
loadElectricDailyRows,
loadElectricKpi,
loadElectricLatestMonthTrend,
loadElectricPreviousDayKwh,
} from './repository.js';
import {
dateRangeClause,
enumerateDateRange,
@@ -23,19 +29,7 @@ export function registerElectricOverviewRoute(
app.get('/electric/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('electric/overview', async () => {
const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT
SUM(kwh) AS totalKwh,
SUM(fee) AS totalFee,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN kwh ELSE 0 END) AS monthKwh,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
FROM bi_ele_charge_record`,
);
const k = kpiRows[0] ?? {};
const k = await loadElectricKpi(pool);
const totalKwh = Number(k.totalKwh) || 0;
const totalFee = Number(k.totalFee) || 0;
const monthKwh = Number(k.monthKwh) || 0;
@@ -44,29 +38,11 @@ export function registerElectricOverviewRoute(
const todayFee = Number(k.todayFee) || 0;
// 本月每日(用于柱图)
const [trendRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
GROUP BY date
ORDER BY date ASC`,
);
const trendRows = await loadElectricCurrentMonthTrend(pool);
// 若本月无数据,降级展示最近一个有数据的自然月
let trend = trendRows;
if (trend.length === 0) {
const [fallback] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
)
GROUP BY date
ORDER BY date ASC`,
);
const fallback = await loadElectricLatestMonthTrend(pool);
trend = fallback;
}
const trendArr = trend.map(r => ({
@@ -82,18 +58,14 @@ export function registerElectricOverviewRoute(
let todayChainPct = 0;
if (todayKwh > 0) {
const [prevRow] = await pool.query<RowDataPacket[]>(
`SELECT SUM(kwh) AS kwh
FROM bi_ele_charge_record
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
);
const prevKwh = Number(prevRow[0]?.kwh) || 0;
const prevKwh = await loadElectricPreviousDayKwh(pool);
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
}
return {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr,
latestChargeTime: typeof k.latestChargeTime === 'string' ? k.latestChargeTime : null,
};
}, { force });
return c.json(data);
@@ -118,17 +90,7 @@ export function registerElectricMonthlyRoute(
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE ${kindClause}
AND ${dateRangeClause('start_time')}
GROUP BY date`,
[dateRange.start, dateRange.end],
);
const rows = await loadElectricDailyRows(pool, { customer, start: dateRange.start, end: dateRange.end });
// 实际数据 map
const dataMap = new Map<string, { kwh: number; fee: number }>();
for (const r of rows) {
+35
View File
@@ -0,0 +1,35 @@
import type { Hono } from 'hono';
import type pool from '../../db/mysql.js';
import type { cached } from './cache.js';
import { loadEtcOverview } from './repository.js';
export interface EtcOverviewDependencies {
pool: Pick<typeof pool, 'query'>;
cached: typeof cached;
}
// ETC 仅聚合已入库的账单与通行明细;空表应明确返回空状态,不能伪造经营数据。
export function registerEtcOverviewRoute(
app: Hono,
{ pool, cached }: EtcOverviewDependencies,
) {
app.get('/etc/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('etc/overview', async () => {
const row = await loadEtcOverview(pool);
const tollRecordCount = Number(row.tollRecordCount) || 0;
const billCount = Number(row.billCount) || 0;
return {
tollRecordCount,
vehicleCount: Number(row.vehicleCount) || 0,
totalAmount: Number(row.totalAmount) || 0,
latestTollTime: typeof row.latestTollTime === 'string' ? row.latestTollTime : null,
billCount,
receivableAmount: Number(row.receivableAmount) || 0,
paidAmount: Number(row.paidAmount) || 0,
hasData: tollRecordCount > 0 || billCount > 0,
};
}, { force });
return c.json(data);
});
}
+652
View File
@@ -0,0 +1,652 @@
import type { Hono } from "hono";
import type { RowDataPacket } from "mysql2";
import type hydrogenPool from "../../db/hydrogen.js";
import {
buildFilterClauses,
buildGroupExpression,
buildGroupSelect,
buildScopedWhere,
loadDailyRows,
loadDailyTreeCustomers,
loadDailyTreeStations,
loadDailyWatermark,
loadDrillGroups,
loadDrillRecords,
loadDrillSummary,
loadMetaStations,
loadMetaWatermark,
loadMetaYears,
loadOverviewCustomers,
loadOverviewMonthly,
loadOverviewStations,
loadOverviewSummary,
resolvedWhere,
} from "./repository.js";
/**
* The BI v2 contract is intentionally independent of the legacy hydrogen
* endpoints. It is shaped around the prototype screens so the client never
* has to merge multiple historical responses or recompute a business metric.
*/
export interface HydrogenBiV2Dependencies {
hydrogenPool: Pick<typeof hydrogenPool, "query">;
}
type VehicleScope = "all" | "lingniu" | "external";
type VerifyScope = "all" | "verified" | "unverified";
type AmountScope = "all" | "customer" | "company" | "other";
type GroupBy = "station" | "customer" | "date" | "vehicle" | "record";
type RegionGranularity = "province" | "city";
type Filter = {
startDate: string;
endDate: string;
// Station IDs are bigint values. Keep them as strings end-to-end: converting
// them to JavaScript Number merges distinct OneOS station IDs above 2^53.
stationId: string | null;
customerId: number | null;
customerName: string | null;
plateNo: string | null;
date: string | null;
month: string | null;
region: string | null;
regionGranularity: RegionGranularity;
vehicleScope: VehicleScope;
verifyScope: VerifyScope;
clauses: string[];
params: unknown[];
};
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
function validDate(value: string | undefined): value is string {
if (!value || !DATE_PATTERN.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
!Number.isNaN(parsed.getTime()) &&
parsed.toISOString().slice(0, 10) === value
);
}
function endOfMonth(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return new Date(Date.UTC(year, monthNumber, 0)).toISOString().slice(0, 10);
}
function todayYmd() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
function dateForYear(year: number, edge: "start" | "end") {
return `${year}-${edge === "start" ? "01-01" : "12-31"}`;
}
function validYear(value: string | undefined) {
const year = Number(value);
// 原型允许查看 2020 起的历史年份。即使真实台账尚未覆盖某一年,
// 也必须保持该筛选范围并返回空聚合,不能静默回退到当前年。
return Number.isInteger(year) && year >= 2020 && year <= 2100 ? year : null;
}
function resolveFilter(query: (key: string) => string | undefined): Filter {
const requestedYear = validYear(query("year"));
const currentYear = new Date().getFullYear();
const year = requestedYear ?? currentYear;
const exactDate = validDate(query("date")) ? query("date")! : null;
const requestedMonth = MONTH_PATTERN.test(query("month") ?? "")
? query("month")!
: null;
const startDate =
exactDate ??
(requestedMonth
? `${requestedMonth}-01`
: validDate(query("startDate"))
? query("startDate")!
: dateForYear(year, "start"));
// A current-year dashboard must never render future zero months/days by
// default. Historical years retain their complete calendar range.
const endDate =
exactDate ??
(requestedMonth
? endOfMonth(requestedMonth)
: validDate(query("endDate"))
? query("endDate")!
: year === currentYear
? todayYmd()
: dateForYear(year, "end"));
const [safeStart, safeEnd] =
startDate <= endDate ? [startDate, endDate] : [endDate, startDate];
const stationValue = query("stationId");
const stationId =
stationValue !== undefined && /^\d+$/.test(stationValue)
? stationValue
: null;
const customerValue = query("customerId");
const customerRaw = Number(customerValue);
const customerId =
customerValue !== undefined &&
Number.isInteger(customerRaw) &&
customerRaw >= 0
? customerRaw
: null;
const customerName =
(query("customerName") ?? "").trim().slice(0, 100) || null;
const plateNo = (query("plateNo") ?? "").trim().slice(0, 32) || null;
const region = (query("region") ?? "").trim().slice(0, 64) || null;
const regionGranularity: RegionGranularity =
query("regionGranularity") === "province" ? "province" : "city";
const vehicleScope: VehicleScope =
query("vehicleScope") === "lingniu" || query("vehicleScope") === "external"
? (query("vehicleScope") as VehicleScope)
: "all";
const verifyScope: VerifyScope =
query("verifyScope") === "verified" || query("verifyScope") === "unverified"
? (query("verifyScope") as VerifyScope)
: "all";
const { clauses, params } = buildFilterClauses({
startDate: safeStart,
endDate: safeEnd,
stationId,
customerId,
customerName,
plateNo,
vehicleScope,
verifyScope,
});
return {
startDate: safeStart,
endDate: safeEnd,
stationId,
customerId,
customerName,
plateNo,
date: exactDate,
month: requestedMonth,
region,
regionGranularity,
vehicleScope,
verifyScope,
clauses,
params,
};
}
function filterContext(filter: Filter) {
return {
startDate: filter.startDate,
endDate: filter.endDate,
stationId: filter.stationId,
customerId: filter.customerId,
customerName: filter.customerName,
plateNo: filter.plateNo,
date: filter.date,
month: filter.month,
region: filter.region,
regionGranularity: filter.regionGranularity,
vehicleScope: filter.vehicleScope,
verifyScope: filter.verifyScope,
};
}
function number(value: unknown, digits = 2) {
const numeric = Number(value) || 0;
const factor = 10 ** digits;
return Math.round(numeric * factor) / factor;
}
// 承担方必须以账本专用字段 settlement_type 为准,不能由对客单价/金额反推。
function resolveAmountScope(value: string | undefined): AmountScope {
return value === "customer" || value === "company" || value === "other"
? value
: "all";
}
function formatWatermark(value: unknown) {
return value === null || value === undefined ? null : String(value);
}
function monthKeys(start: string, end: string) {
const list: string[] = [];
const cursor = new Date(`${start.slice(0, 7)}-01T00:00:00`);
const finish = new Date(`${end.slice(0, 7)}-01T00:00:00`);
while (cursor <= finish && list.length <= 36) {
list.push(
`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`,
);
cursor.setMonth(cursor.getMonth() + 1);
}
return list;
}
function dateKeys(start: string, end: string) {
const list: string[] = [];
const cursor = new Date(`${start}T00:00:00`);
const finish = new Date(`${end}T00:00:00`);
while (cursor <= finish && list.length <= 400) {
list.push(
`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}-${String(cursor.getDate()).padStart(2, "0")}`,
);
cursor.setDate(cursor.getDate() + 1);
}
return list;
}
async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
const [yearRows, stationRows, watermarkRows] = await Promise.all([
loadMetaYears(hydrogenPool),
loadMetaStations(hydrogenPool),
loadMetaWatermark(hydrogenPool),
]);
return {
years: yearRows.map((row) => ({
value: Number(row.value),
startDate: String(row.startDate),
endDate: String(row.endDate),
})),
stations: stationRows.map((row) => ({
id: String(row.id),
name: String(row.name),
province: String(row.province),
city: String(row.city),
})),
watermark: {
ledgerAt: formatWatermark(watermarkRows[0]?.ledgerAt),
paymentAt: null,
},
};
}
async function overview(
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
filter: Filter,
) {
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
const month = filter.endDate.slice(0, 7);
const [summaryRows, monthlyRows, stationRows, customerRows] =
await Promise.all([
loadOverviewSummary(hydrogenPool, { sqlWhere, params, month, endDate: filter.endDate }),
loadOverviewMonthly(hydrogenPool, { sqlWhere, params }),
loadOverviewStations(hydrogenPool, { sqlWhere, params }),
loadOverviewCustomers(hydrogenPool, { sqlWhere, params }),
]);
const summary: RowDataPacket = summaryRows[0] ?? ({} as RowDataPacket);
const totalKg = number(summary.totalKg, 3);
const totalRevenue = number(summary.totalRevenue);
const customerRevenue = number(summary.customerRevenue);
const customerCost = number(summary.customerCost);
const monthlyMap = new Map(
monthlyRows.map((row) => [String(row.month), row]),
);
const monthly = monthKeys(filter.startDate, filter.endDate).map((key) => {
const row = monthlyMap.get(key);
const revenue = number(row?.revenue);
const customerMonthRevenue = number(row?.customerRevenue);
const cost = number(row?.cost);
const customerMonthCost = number(row?.customerCost);
const companyMonthCost = number(row?.companyCost);
const otherMonthCost = number(row?.otherCost);
return {
month: key,
totalKg: number(row?.totalKg, 3),
lingniuKg: number(row?.lingniuKg, 3),
externalKg: number(row?.externalKg, 3),
cost,
revenue,
customerRevenue: customerMonthRevenue,
customerCost: customerMonthCost,
companyCost: companyMonthCost,
otherCost: otherMonthCost,
customerGrossProfit: number(customerMonthRevenue - customerMonthCost),
};
});
const stations = stationRows.map((row) => ({
id: String(row.id),
name: String(row.name),
province: String(row.province),
city: String(row.city),
kg: number(row.kg, 3),
lingniuKg: number(row.lingniuKg, 3),
externalKg: number(row.externalKg, 3),
cost: number(row.cost),
revenue: number(row.revenue),
customerRevenue: number(row.customerRevenue),
customerCost: number(row.customerCost),
companyCost: number(row.companyCost),
otherCost: number(row.otherCost),
recordCount: Number(row.recordCount) || 0,
customerCount: Number(row.customerCount) || 0,
share: totalKg ? number((Number(row.kg) / totalKg) * 100, 2) : 0,
}));
// The station aggregation above already joins the district dimension. Reusing
// it avoids a second full-ledger scan with a correlated subquery for regions.
const regionMap = new Map<string, number>();
for (const station of stations) {
const rawRegion =
filter.regionGranularity === "province" ? station.province : station.city;
const region =
rawRegion === "未归属"
? filter.regionGranularity === "province"
? "未归属省份"
: "未归属区域"
: rawRegion;
regionMap.set(region, (regionMap.get(region) || 0) + station.kg);
}
const regionSum = [...regionMap.values()].reduce((sum, kg) => sum + kg, 0);
const regions = [...regionMap.entries()]
.map(([region, kg]) => ({
region,
kg: number(kg, 3),
share: regionSum ? number((kg / regionSum) * 100, 2) : 0,
}))
.sort((left, right) => right.kg - left.kg);
return {
range: { startDate: filter.startDate, endDate: filter.endDate },
watermark: { ledgerAt: formatWatermark(summary.ledgerAt), paymentAt: null },
filters: filterContext(filter),
kpis: {
totalKg,
totalCost: number(summary.totalCost),
customerBearingKg: number(summary.customerBearingKg, 3),
companyBearingKg: number(summary.companyBearingKg, 3),
otherBearingKg: number(summary.otherBearingKg, 3),
totalRevenue,
customerRevenue,
customerCost,
companyCost: number(summary.companyCost),
otherCost: number(summary.otherCost),
customerGrossProfit: number(customerRevenue - customerCost),
monthKg: number(summary.monthKg, 3),
monthCost: number(summary.monthCost),
monthShareOfRange: totalKg
? number((number(summary.monthKg, 3) / totalKg) * 100, 2)
: 0,
todayKg: number(summary.todayKg, 3),
todayCost: number(summary.todayCost),
month: filter.endDate.slice(0, 7),
todayShareOfMonth: number(summary.monthKg, 3)
? number(
(number(summary.todayKg, 3) / number(summary.monthKg, 3)) * 100,
2,
)
: 0,
recordCount: Number(summary.recordCount) || 0,
stationCount: Number(summary.stationCount) || 0,
},
monthly,
topStations: stations.slice(0, 5),
stations,
regions,
customers: customerRows.map((row) => {
const customerBearingKg = number(row.customerBearingKg, 3);
const companyBearingKg = number(row.companyBearingKg, 3);
const otherBearingKg = number(row.otherBearingKg, 3);
return {
id: Number(row.id) || 0,
name: String(row.name),
kg: number(row.kg, 3),
customerBearingKg,
companyBearingKg,
otherBearingKg,
bearer:
otherBearingKg > 0
? "other"
: customerBearingKg > 0 && companyBearingKg > 0
? "both"
: customerBearingKg > 0
? "customer"
: "company",
cost: number(row.cost),
revenue: number(row.revenue),
customerRevenue: number(row.customerRevenue),
customerCost: number(row.customerCost),
companyCost: number(row.companyCost),
otherCost: number(row.otherCost),
recordCount: Number(row.recordCount) || 0,
};
}),
};
}
async function daily(
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
filter: Filter,
) {
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
const [rows, watermarkRows] = await Promise.all([
loadDailyRows(hydrogenPool, { sqlWhere, params }),
loadDailyWatermark(hydrogenPool, { sqlWhere, params }),
]);
const map = new Map(rows.map((row) => [String(row.date), row]));
let previousKg = 0;
const days = dateKeys(filter.startDate, filter.endDate).map((date) => {
const row = map.get(date);
const kg = number(row?.kg, 3);
const chainPct = previousKg
? number(((kg - previousKg) / previousKg) * 100, 2)
: 0;
previousKg = kg;
return {
date,
kg,
lingniuKg: number(row?.lingniuKg, 3),
externalKg: number(row?.externalKg, 3),
cost: number(row?.cost),
recordCount: Number(row?.recordCount) || 0,
stationCount: Number(row?.stationCount) || 0,
chainPct,
};
});
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
return {
range: { startDate: filter.startDate, endDate: filter.endDate },
watermark: {
ledgerAt: formatWatermark(watermarkRows[0]?.ledgerAt),
paymentAt: null,
},
filters: filterContext(filter),
kpis: {
totalKg: number(totalKg, 3),
totalCost: number(totalCost),
averageDailyKg: number(totalKg / Math.max(1, days.length), 3),
stationCount: Math.max(0, ...days.map((row) => row.stationCount)),
activeDays: days.filter((row) => row.kg > 0).length,
},
trend: days,
days: [...days].reverse(),
};
}
/**
* Prototype daily detail is a real hierarchy, not a flattened table: one
* business day expands into station, customer and then the existing record
* drill. Records remain paged through /drill to keep a busy day bounded.
*/
async function dailyTree(
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
filter: Filter,
) {
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
const [stationRows, customerRows] = await Promise.all([
loadDailyTreeStations(hydrogenPool, { sqlWhere, params }),
loadDailyTreeCustomers(hydrogenPool, { sqlWhere, params }),
]);
const customersByStation = new Map<
string,
Array<{
id: number;
name: string;
kg: number;
cost: number;
recordCount: number;
}>
>();
customerRows.forEach((row) => {
const stationId = String(row.stationId);
const list = customersByStation.get(stationId) || [];
list.push({
id: Number(row.id) || 0,
name: String(row.name),
kg: number(row.kg, 3),
cost: number(row.cost),
recordCount: Number(row.recordCount) || 0,
});
customersByStation.set(stationId, list);
});
return {
date: filter.startDate,
stations: stationRows.map((row) => ({
id: String(row.id),
name: String(row.name),
kg: number(row.kg, 3),
cost: number(row.cost),
recordCount: Number(row.recordCount) || 0,
customers: customersByStation.get(String(row.id)) || [],
})),
};
}
async function drill(
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
filter: Filter,
groupBy: GroupBy,
page: number,
pageSize: number,
amountScope: AmountScope,
) {
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
const scopedWhere = buildScopedWhere(sqlWhere, amountScope);
const groupSelect = buildGroupSelect(groupBy);
const groupExpression = buildGroupExpression(groupBy);
const offset = (page - 1) * pageSize;
// Stable tie-breakers prevent equal-volume groups drifting between pages.
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC, id ASC, name ASC";
const groupHaving =
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
const [summaryRows, groupRows, recordRows] = await Promise.all([
loadDrillSummary(hydrogenPool, { scopedWhere, params }),
groupBy === "record"
? Promise.resolve([] as RowDataPacket[])
: loadDrillGroups(hydrogenPool, { scopedWhere, params, groupSelect, groupExpression, groupHaving, groupOrder, pageSize, offset }),
groupBy === "record"
? loadDrillRecords(hydrogenPool, { scopedWhere, params, pageSize, offset })
: Promise.resolve([] as RowDataPacket[]),
]);
const summary = summaryRows[0] ?? {};
return {
groupBy,
amountScope,
filters: filterContext(filter),
summary: {
recordCount: Number(summary.recordCount) || 0,
stationCount: Number(summary.stationCount) || 0,
customerCount: Number(summary.customerCount) || 0,
traceableRecordCount: Number(summary.traceableRecordCount) || 0,
kg: number(summary.kg, 3),
cost: number(summary.cost),
revenue: number(summary.revenue),
},
groups: groupRows.map((row) => ({
id: String(row.id),
name: String(row.name),
province: row.province ? String(row.province) : null,
city: row.city ? String(row.city) : null,
recordCount: Number(row.recordCount) || 0,
stationCount: Number(row.stationCount) || 0,
customerCount: Number(row.customerCount) || 0,
kg: number(row.kg, 3),
cost: number(row.cost),
revenue: number(row.revenue),
lingniuKg: number(row.lingniuKg, 3),
externalKg: number(row.externalKg, 3),
settlementTypes: row.settlementTypes == null ? null : String(row.settlementTypes),
})),
records: recordRows.map((row) => ({
settlementType: row.settlementType == null ? null : String(row.settlementType),
id: String(row.id),
time: String(row.time),
orderNo: String(row.orderNo || ""),
stationId: String(row.stationId),
stationName: String(row.stationName),
customerId: Number(row.customerId) || 0,
customerName: String(row.customerName),
plateNo: String(row.plateNo),
source: String(row.source),
verifyStatus: String(row.verifyStatus),
vehicleScope:
row.vehicleId === null || row.vehicleId === undefined
? "external"
: "lingniu",
kg: number(row.kg, 3),
unitPrice: number(row.unitPrice),
cost: number(row.cost),
revenue: number(row.revenue),
})),
page: { page, pageSize, hasMore: (groupBy === "record" ? recordRows : groupRows).length === pageSize },
};
}
export function registerHydrogenBiV2Routes(
app: Hono,
{ hydrogenPool }: HydrogenBiV2Dependencies,
) {
app.get("/h2/v2/meta", async (c) => c.json(await meta(hydrogenPool)));
app.get("/h2/v2/overview", async (c) =>
c.json(
await overview(
hydrogenPool,
resolveFilter((key) => c.req.query(key)),
),
),
);
app.get("/h2/v2/daily", async (c) =>
c.json(
await daily(
hydrogenPool,
resolveFilter((key) => c.req.query(key)),
),
),
);
app.get("/h2/v2/daily-tree", async (c) => {
const date = c.req.query("date");
if (!validDate(date))
return c.json({ message: "date 必须为有效的 YYYY-MM-DD" }, 400);
const filter = resolveFilter((key) =>
key === "date" ? date : c.req.query(key),
);
return c.json(await dailyTree(hydrogenPool, filter));
});
app.get("/h2/v2/drill", async (c) => {
const groupBy: GroupBy = [
"station",
"customer",
"date",
"vehicle",
"record",
].includes(c.req.query("groupBy") ?? "")
? (c.req.query("groupBy") as GroupBy)
: "station";
const requestedPage = Number(c.req.query("page"));
const page =
Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
const requestedSize = Number(c.req.query("pageSize"));
const pageSize =
Number.isInteger(requestedSize) && requestedSize > 0
? Math.min(requestedSize, 200)
: 100;
return c.json(
await drill(
hydrogenPool,
resolveFilter((key) => c.req.query(key)),
groupBy,
page,
pageSize,
resolveAmountScope(c.req.query("amountScope")),
),
);
});
}
-145
View File
@@ -1,145 +0,0 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import {
HYDROGEN_BASE_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
} from './constants.js';
import {
customerClause,
dateRangeClause,
enumerateDateRange,
resolveDateRange,
type CustomerKind,
type EnergyDateRangeKind as Range,
} from './query-model.js';
export interface HydrogenDailyDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
// 氢能每日:日期范围 + 客户类型 + 站点级下钻。
export function registerHydrogenDailyRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenDailyDependencies,
) {
app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const customer = (c.req.query('customer') || 'external') as CustomerKind;
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
const where = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
// 单价不重算:直接取账本成本价。
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
ROUND(SUM(b.amount_kg), 2) AS kg,
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
MAX(b.cost_price) AS pricePerKg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${where}
GROUP BY d, COALESCE(b.station_id, 0)
ORDER BY d DESC, kg DESC`,
[dateRange.start, dateRange.end],
);
// 站点环比:同站点上一条记录的 kg
// 按 stationId 分组、按日期升序计算
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
const flat: StationRow[] = stationRows.map(r => ({
date: r.d as string,
stationId: Number(r.stationId),
name: r.stationName as string,
kg: Number(r.kg) || 0,
pricePerKg: Number(r.pricePerKg) || 0,
}));
// 计算日级总量 + 日级环比
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
for (const s of flat) {
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
const e = dayMap.get(s.date)!;
e.totalKg += s.kg;
e.stations.push(s);
}
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
const dayChainPct = new Map<string, number>();
let prev = 0;
for (const d of dates) {
const cur = dayMap.get(d)!.totalKg;
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
prev = cur;
}
// 站点级环比:按 stationId 分组按日期升序
const stationPrev = new Map<number, number>();
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
// 需要按 stationId 分组排序
const byStation = new Map<number, StationRow[]>();
for (const s of flat) {
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
byStation.get(s.stationId)!.push(s);
}
for (const [, list] of byStation) {
list.sort((a, b) => a.date.localeCompare(b.date));
let p = 0;
for (const r of list) {
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
p = r.kg;
}
}
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const fullDays = allDates.map(date => {
const info = dayMap.get(date);
return {
date,
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
chainPct: dayChainPct.get(date) ?? 0,
customerType: customer,
stations: info
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
name: s.name,
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
kg: Math.round(s.kg * 100) / 100,
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
}))
: [],
};
});
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
let prevKg = 0;
for (const d of ascDays) {
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
prevKg = d.totalKg;
}
// 按日期降序返回
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
return result;
}, { force });
return c.json(data);
});
}
@@ -1,273 +0,0 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import {
HYDROGEN_BASE_WHERE,
HYDROGEN_BASE_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
} from './constants.js';
export interface HydrogenOverviewDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
// 氢能总览:KPI + Top5 + 区域占比。
export function registerHydrogenOverviewRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenOverviewDependencies,
) {
app.get('/hydrogen/overview', async (c) => {
const yearParam = c.req.query('year');
const force = c.req.query('force') === '1';
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
const isCurrentYear = year === todayYear;
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN amount_kg ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN cost_total ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS yearCustomerCost,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN fee_total ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN amount_kg ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN cost_total ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN amount_kg ELSE 0 END) AS customerYearKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN amount_kg ELSE 0 END) AS monthKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN cost_total ELSE 0 END) AS monthFee,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee_total ELSE 0 END) AS monthRevenue,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN amount_kg ELSE 0 END) AS todayKg,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN cost_total ELSE 0 END) AS todayFee,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN fee_total ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
[year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
HYDROGEN_MIN_DATE],
);
const k = kpiRows[0] ?? {};
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
const monthFee = Number(k.monthFee) || 0;
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
const monthRevenue = Number(k.monthRevenue) || 0;
const todayFee = Number(k.todayFee) || 0;
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
const todayRevenue = Number(k.todayRevenue) || 0;
const kpi = {
yearKg: Number(k.yearKg) || 0,
yearFee,
yearRevenue,
yearProfit: yearRevenue - yearCustomerCost,
ourYearKg: Number(k.ourYearKg) || 0,
ourYearFee: Number(k.ourYearFee) || 0,
customerYearKg: Number(k.customerYearKg) || 0,
monthKg: Number(k.monthKg) || 0,
monthFee,
monthRevenue,
monthProfit: monthRevenue - monthCustomerCost,
todayKg: Number(k.todayKg) || 0,
todayFee,
todayRevenue,
todayProfit: todayRevenue - todayCustomerCost,
lingniuBornKg: Number(k.lingniuBornKg) || 0,
lingniuBornFee: Number(k.lingniuBornFee) || 0,
};
// Top5 加氢站(指定年份)
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.cost_total) AS fee
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE, year],
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
rank: i + 1,
name: r.name as string,
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
share: (Number(r.kg) || 0) / top5KgSum,
}));
// 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.fee_total) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
name: r.name as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
}));
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT CASE
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
END AS region,
b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
) r
GROUP BY region
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8;
const top = regionRows.slice(0, TOP_REGIONS);
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
const regions = [
...top.map(r => ({
region: r.region as string,
kg: Number(r.kg) || 0,
share: (Number(r.kg) || 0) / totalKg,
})),
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
];
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(amount_kg), 2) AS kg,
ROUND(SUM(cost_total), 2) AS fee,
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
ROUND(SUM(fee_total), 2) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE, year],
);
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
for (let mi = 1; mi <= lastMonth; mi++) {
const key = `${year}-${String(mi).padStart(2, '0')}`;
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
// 客户账单 Top(指定年份;按加氢量降序,前 30)
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
ELSE 'customer' END AS payer,
SUM(amount_kg) AS kg,
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
kg: Number(r.kg) || 0,
cost: Number(r.cost) || 0,
revenue: Number(r.revenue) || 0,
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
}, { force });
return c.json(data);
});
}
@@ -0,0 +1,196 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../db/hydrogen.js';
import type { cached } from './cache.js';
import {
loadCustomerMonth,
loadExternalCustomerMonth,
loadExternalReceipts,
loadRangePayments,
loadStationDaily,
loadStationDailyForStation,
loadStationList,
loadStationPaymentDaily,
loadSummaryDaily,
loadSummaryPaymentDaily,
} from './repository.js';
import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js';
export interface HydrogenStationBoardDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
function safeStationId(value: string | undefined): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function numberValue(value: unknown): number {
return Number(value) || 0;
}
// 单站接口没有租户入参,租户只能由服务端配置决定,不能接受浏览器传值。
// 单站经营看板只做只读聚合。列表仅返回所选区间内存在有效加氢记录的站点。
export function registerHydrogenStationBoardRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenStationBoardDependencies,
) {
app.get('/hydrogen/station-board', async (c) => {
const range = resolveDateRange(
'last15',
c.req.query('startDate'),
c.req.query('endDate'),
);
const stationId = safeStationId(c.req.query('stationId'));
const force = c.req.query('force') === '1';
const cacheKey = `hydrogen/station-board?start=${range.start}&end=${range.end}${stationId ? `&station=${stationId}` : ''}`;
const data = await cached(cacheKey, async () => {
const stationRows = await loadStationList(hydrogenPool, { start: range.start, end: range.end });
const paymentRows = await loadRangePayments(hydrogenPool, { start: range.start, end: range.end });
const summaryDailyRows = await loadSummaryDaily(hydrogenPool, { start: range.start, end: range.end });
const stationDailyRows = await loadStationDaily(hydrogenPool, { start: range.start, end: range.end });
const summaryPaymentDailyRows = await loadSummaryPaymentDaily(hydrogenPool, { start: range.start, end: range.end });
const paymentByStation = new Map(paymentRows.map(row => [numberValue(row.stationId), row]));
const dailyKgByStation = new Map<number, Map<string, number>>();
for (const row of stationDailyRows) {
const id = numberValue(row.stationId);
const values = dailyKgByStation.get(id) ?? new Map<string, number>();
values.set(String(row.date), numberValue(row.kg));
dailyKgByStation.set(id, values);
}
const totalKg = stationRows.reduce((sum, row) => sum + numberValue(row.kg), 0);
const stations = stationRows.map(row => {
const id = numberValue(row.id);
const payment = paymentByStation.get(id);
return {
id,
name: String(row.name),
province: String(row.province),
city: String(row.city),
kg: numberValue(row.kg),
fee: numberValue(row.fee),
recordCount: numberValue(row.recordCount),
paymentAmount: numberValue(payment?.amount),
paymentCount: numberValue(payment?.paymentCount),
share: totalKg > 0 ? numberValue(row.kg) / totalKg : 0,
latestLedgerTime: typeof row.latestLedgerTime === 'string' ? row.latestLedgerTime : null,
latestPaymentDate: typeof payment?.latestPaymentDate === 'string' ? payment.latestPaymentDate : null,
dailyKg: enumerateDateRange(range.start, range.end).map(date => ({
date,
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
})),
};
}).filter(station => station.recordCount > 0
|| station.name.includes('东鹏大道')
|| (station.name.includes('佛山南海') && station.name.includes('羚牛')));
let selected = null;
if (stationId) {
const dailyRows = await loadStationDailyForStation(hydrogenPool, { stationId: stationId as number, start: range.start, end: range.end });
const dailyPaymentRows = await loadStationPaymentDaily(hydrogenPool, { stationId: stationId as number, start: range.start, end: range.end });
const paymentByDate = new Map(dailyPaymentRows.map(row => [String(row.date), row]));
const ledgerByDate = new Map(dailyRows.map(row => [String(row.date), row]));
let previousKg = 0;
const daily = enumerateDateRange(range.start, range.end).map(date => {
const ledger = ledgerByDate.get(date);
const kg = numberValue(ledger?.kg);
const payment = paymentByDate.get(date);
const result = {
date,
kg,
fee: numberValue(ledger?.fee),
avgPrice: numberValue(ledger?.avgPrice),
recordCount: numberValue(ledger?.recordCount),
changeKg: kg - previousKg,
paymentAmount: numberValue(payment?.amount),
paymentCount: numberValue(payment?.paymentCount),
};
previousKg = kg;
return result;
});
const customerMonthRows = await loadCustomerMonth(hydrogenPool, { stationId: stationId as number, end: range.end });
const externalCustomerMonthRows = await loadExternalCustomerMonth(hydrogenPool, { stationId: stationId as number, end: range.end });
const externalReceiptRows = await loadExternalReceipts(hydrogenPool, { start: range.start, end: range.end });
selected = {
daily,
customerMonths: customerMonthRows.map(row => ({
month: String(row.month),
customerName: String(row.customerName),
kg: numberValue(row.kg),
fee: numberValue(row.fee),
recordCount: numberValue(row.recordCount),
})),
externalCustomerMonths: externalCustomerMonthRows.map(row => ({
month: String(row.month),
customerName: String(row.customerName),
kg: numberValue(row.kg),
fee: numberValue(row.fee),
recordCount: numberValue(row.recordCount),
})),
// 快照和手工充值都没有可靠站点归属,不能把客户级进账误报为当前站点收益。
externalReceipts: {
scope: 'customer',
reason: '当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。',
rows: externalReceiptRows.map(row => ({
id: String(row.id),
date: String(row.date),
customerName: String(row.customerName),
amount: numberValue(row.amount),
payMethod: String(row.payMethod),
source: String(row.source),
sourceRecordCount: numberValue(row.sourceRecordCount),
updatedAt: typeof row.updatedAt === 'string' ? row.updatedAt : null,
})),
},
};
}
const paymentTotal = stations.reduce((sum, station) => sum + station.paymentAmount, 0);
const summaryLedgerByDate = new Map(summaryDailyRows.map(row => [String(row.date), row]));
const summaryPaymentByDate = new Map(summaryPaymentDailyRows.map(row => [String(row.date), row]));
const summaryDaily = enumerateDateRange(range.start, range.end).map(date => {
const ledger = summaryLedgerByDate.get(date);
const payment = summaryPaymentByDate.get(date);
return {
date,
kg: numberValue(ledger?.kg),
fee: numberValue(ledger?.fee),
recordCount: numberValue(ledger?.recordCount),
paymentAmount: numberValue(payment?.amount),
paymentCount: numberValue(payment?.paymentCount),
};
});
const latestLedgerTime = stations
.map(station => station.latestLedgerTime)
.filter((value): value is string => Boolean(value))
.sort()
.at(-1) ?? null;
return {
range,
summary: {
stationCount: stations.length,
activeStationCount: stations.filter(station => station.kg > 0).length,
totalKg,
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
recordCount: stations.reduce((sum, station) => sum + station.recordCount, 0),
paymentAmount: paymentTotal,
paymentCount: stations.reduce((sum, station) => sum + station.paymentCount, 0),
latestLedgerTime,
daily: summaryDaily,
},
stations,
selected,
};
}, { force });
return c.json(data);
});
}
+13 -9
View File
@@ -1,23 +1,24 @@
import { Hono } from 'hono';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import pool from '../../db.js';
import hydrogenPool from '../../hydrogen-db.js';
import pool from '../../db/mysql.js';
import hydrogenPool from '../../db/hydrogen.js';
import { cached } from './cache.js';
import {
registerElectricMonthlyRoute,
registerElectricOverviewRoute,
} from './electric.js';
import { registerHydrogenDailyRoute } from './hydrogen-daily.js';
import { registerHydrogenOverviewRoute } from './hydrogen-overview.js';
import { registerEtcOverviewRoute } from './etc.js';
import { registerHydrogenStationBoardRoute } from './hydrogen-station-board.js';
import { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js';
const app = new Hono();
// 模块级访问守卫:dev 旁路 auth 时 user 为 undefined,直接放行;
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
// 模块级访问守卫(fail-closed):必须持有 BI-LEADER-ENERGY 或「所有权限」。
// 认证中间件挂在本路由之前,因此 user 缺失本身就是异常,按无权限处理。
app.use('*', async (c, next) => {
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
@@ -25,9 +26,12 @@ app.use('*', async (c, next) => {
const cacheDependency = { cached };
registerHydrogenOverviewRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenDailyRoute(app, { ...cacheDependency, hydrogenPool });
// 氢能经营看板:/hydrogen/station-board 仍被单站日报页面使用;/h2/v2/* 为当前看板所用。
// 注册顺序沿用历史顺序(station-board 在 h2/v2 之前),以免影响路由匹配语义。
registerHydrogenStationBoardRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenBiV2Routes(app, { hydrogenPool });
registerElectricOverviewRoute(app, { ...cacheDependency, pool });
registerElectricMonthlyRoute(app, { ...cacheDependency, pool });
registerEtcOverviewRoute(app, { ...cacheDependency, pool });
export default app;
+3 -3
View File
@@ -7,9 +7,9 @@ import {
resolveDateRange,
} from './query-model.js';
test('客户范围继续让外部车辆返回空数据', () => {
assert.equal(customerClause('external'), '1=0');
assert.equal(customerClause('lingniu'), '1=1');
test('车辆归属按账本 vehicle_id 区分羚牛和外部车辆', () => {
assert.equal(customerClause('external'), 'vehicle_id IS NULL');
assert.equal(customerClause('lingniu'), 'vehicle_id IS NOT NULL');
assert.equal(customerClause('all'), '1=1');
});
+4 -3
View File
@@ -8,10 +8,11 @@ export interface EnergyDateRange {
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。
// 车辆归属以账本 vehicle_id 为准,与氢能总览的筛选口径一致。
// 账本未关联车辆的记录归入外部车辆,避免在日报中遗漏真实加氢数据。
export function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
if (customer === 'external') return 'vehicle_id IS NULL';
if (customer === 'lingniu') return 'vehicle_id IS NOT NULL';
return '1=1';
}
+777
View File
@@ -0,0 +1,777 @@
import type { RowDataPacket } from 'mysql2';
import { dateRangeClause, type CustomerKind } from './query-model.js';
import {
COMPANY_BEARING_ORDER,
CUSTOMER_BEARING_ORDER,
EXTERNAL_CUSTOMER_MAP_CTE,
OTHER_BEARING_ORDER,
HYDROGEN_BASE_WHERE,
HYDROGEN_BASE_WHERE_B,
HYDROGEN_FUEL_ONLY_WHERE,
HYDROGEN_FUEL_ONLY_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
HYDROGEN_TENANT_ID,
HYDROGEN_VERIFY_START_DATE,
MANUAL_RECHARGE_SOURCE,
NORMALIZED_PLATE,
NORMALIZED_PLATE_UNICODE,
} from './constants.js';
/**
* 能源域的数据访问。
*
* 电能 / ETC / 单站日报 / 氢能 v2 的 SQL 都集中在这里;路由只做校验、编排与口径计算。
* SQL 文本与参数顺序由 routes.test.ts 的契约测试锁定(该文件对多处查询做了指纹断言)。
*/
/** 只需要 query 能力的连接(主库或氢能库)。 */
export interface Database {
query<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
// ---------------------------------------------------------------------------
// ETC
// ---------------------------------------------------------------------------
/** ETC 台账与账单汇总;空表返回全 0,由路由判定 hasData。 */
export async function loadEtcOverview(db: Database): Promise<Record<string, unknown>> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT
(SELECT COUNT(*) FROM etc_toll_record WHERE del_flag = '0') AS tollRecordCount,
(SELECT COUNT(DISTINCT plate_number) FROM etc_toll_record WHERE del_flag = '0') AS vehicleCount,
(SELECT ROUND(SUM(total_amount), 2) FROM etc_toll_record WHERE del_flag = '0') AS totalAmount,
(SELECT DATE_FORMAT(MAX(trans_time), '%Y-%m-%d %H:%i:%s') FROM etc_toll_record WHERE del_flag = '0') AS latestTollTime,
(SELECT COUNT(*) FROM energy_etc_bill WHERE del_flag = '0') AS billCount,
(SELECT ROUND(SUM(receivable_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS receivableAmount,
(SELECT ROUND(SUM(paid_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS paidAmount`,
);
return (rows[0] ?? {}) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// 电能
// ---------------------------------------------------------------------------
/** 电能总览 KPI(全量 + 本月 + 本日)。 */
export async function loadElectricKpi(db: Database): Promise<Record<string, unknown>> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT
SUM(kwh) AS totalKwh,
SUM(fee) AS totalFee,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN kwh ELSE 0 END) AS monthKwh,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee,
DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestChargeTime
FROM bi_ele_charge_record`,
);
return (rows[0] ?? {}) as Record<string, unknown>;
}
/** 本月逐日电量/费用(柱图)。 */
export async function loadElectricCurrentMonthTrend(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
GROUP BY date
ORDER BY date ASC`,
);
return rows;
}
/** 本月无数据时的降级:最近一个有数据的自然月。 */
export async function loadElectricLatestMonthTrend(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
)
GROUP BY date
ORDER BY date ASC`,
);
return rows;
}
/** 昨日电量(用于本日环比)。 */
export async function loadElectricPreviousDayKwh(db: Database): Promise<number> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT SUM(kwh) AS kwh
FROM bi_ele_charge_record
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
);
return Number(rows[0]?.kwh) || 0;
}
/** 车辆归属筛选片段。internal=我司,external=外部;其余不过滤。 */
export function electricKindClause(customer: CustomerKind): string {
if (customer === 'lingniu') return `vehicle_kind = 'internal'`;
if (customer === 'external') return `vehicle_kind = 'external'`;
return '1=1';
}
/** 按日电量/费用(区间)。 */
export async function loadElectricDailyRows(
db: Database,
args: { customer: CustomerKind; start: string; end: string },
): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE ${electricKindClause(args.customer)}
AND ${dateRangeClause('start_time')}
GROUP BY date`,
[args.start, args.end],
);
return rows;
}
// ---------------------------------------------------------------------------
// 氢能单站日报(/hydrogen/station-board)
// ---------------------------------------------------------------------------
/** 区间内有有效加氢记录的站点列表。 */
export async function loadStationList(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT s.id,
COALESCE(NULLIF(s.station_short_name, ''), NULLIF(s.station_name, ''), CONCAT('未知站点 #', s.id)) AS name,
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
ROUND(COALESCE(SUM(b.amount_kg), 0), 2) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS fee,
COUNT(b.id) AS recordCount,
DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
FROM hydrogen_station s
LEFT JOIN ${HYDROGEN_TABLE} b
ON b.station_id = s.id
AND ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
LEFT JOIN common_district dp
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dp.STATUS = 'VALID'
LEFT JOIN common_district dc
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dc.STATUS = 'VALID'
WHERE s.del_flag = '0'
GROUP BY s.id, s.station_short_name, s.station_name, dp.NAME, s.province, dc.NAME, s.city
ORDER BY kg DESC, name ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
return rows;
}
/** 区间内现结流水(按站点汇总)。 */
export async function loadRangePayments(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT station_id AS stationId,
ROUND(SUM(amount), 2) AS amount,
COUNT(*) AS paymentCount,
DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate
FROM hydrogen_station_payment
WHERE del_flag = '0'
AND payment_date >= ?
AND payment_date <= ?
GROUP BY station_id`, [start, end]);
return rows;
}
/** 全部站点逐日汇总。 */
export async function loadSummaryDaily(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(b.cost_total), 2) AS fee,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
return rows;
}
/** 站点逐日汇总。 */
export async function loadStationDaily(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT b.station_id AS stationId,
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
ROUND(SUM(b.amount_kg), 2) AS kg
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY b.station_id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY stationId ASC, date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
return rows;
}
/** 全部站点逐日现结。 */
export async function loadSummaryPaymentDaily(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
ROUND(SUM(amount), 2) AS amount,
COUNT(*) AS paymentCount
FROM hydrogen_station_payment
WHERE del_flag = '0'
AND payment_date >= ?
AND payment_date <= ?
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
ORDER BY date ASC`, [start, end]);
return rows;
}
/** 指定站点逐日加氢。 */
export async function loadStationDailyForStation(db: Database, args: { stationId: number; start: string; end: string }): Promise<RowDataPacket[]> {
const { stationId, start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(b.cost_total), 2) AS fee,
ROUND(AVG(NULLIF(b.cost_price, 0)), 2) AS avgPrice,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND b.station_id = ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, start, end]);
return rows;
}
/** 指定站点逐日现结。 */
export async function loadStationPaymentDaily(db: Database, args: { stationId: number; start: string; end: string }): Promise<RowDataPacket[]> {
const { stationId, start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
ROUND(SUM(amount), 2) AS amount,
COUNT(*) AS paymentCount
FROM hydrogen_station_payment
WHERE del_flag = '0'
AND station_id = ?
AND payment_date >= ?
AND payment_date <= ?
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
ORDER BY date ASC`, [stationId, start, end]);
return rows;
}
/** 指定站点的客户月度汇总。 */
export async function loadCustomerMonth(db: Database, args: { stationId: number; end: string }): Promise<RowDataPacket[]> {
const { stationId, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`${EXTERNAL_CUSTOMER_MAP_CTE}
SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户') AS customerName,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(b.cost_total), 2) AS fee,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
LEFT JOIN external_vehicle_map m
ON CONVERT(${NORMALIZED_PLATE('b.license_plate')} USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(m.normalizedPlate USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND (b.vehicle_id IS NULL OR b.vehicle_id = 0)
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND b.station_id = ?
AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH)
AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'),
COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户')
ORDER BY month ASC, kg DESC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, end, end]);
return rows;
}
/** 指定站点的外部客户月度汇总(含映射 CTE)。 */
export async function loadExternalCustomerMonth(db: Database, args: { stationId: number; end: string }): Promise<RowDataPacket[]> {
const { stationId, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`${EXTERNAL_CUSTOMER_MAP_CTE}
SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
m.customerName,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(COALESCE(b.fee_total, b.cost_total)), 2) AS fee,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
INNER JOIN external_vehicle_map m
ON ${NORMALIZED_PLATE_UNICODE('b.license_plate')}
= m.normalizedPlate
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND b.station_id = ?
AND (b.vehicle_id IS NULL OR b.vehicle_id = 0)
AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH)
AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), m.customerName
ORDER BY month ASC, kg DESC, m.customerName ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, end, end]);
return rows;
}
/** 外部客户进账(自动 + 手工充值合并)。 */
export async function loadExternalReceipts(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT * FROM (
SELECT CONCAT('auto:', CAST(id AS CHAR)) AS id,
DATE_FORMAT(summary_date, '%Y-%m-%d') AS date,
customer_name AS customerName,
amount,
pay_method AS payMethod,
source,
spot_record_count AS sourceRecordCount,
DATE_FORMAT(COALESCE(refresh_time, update_time), '%Y-%m-%d %H:%i:%s') AS updatedAt
FROM hydrogen_external_recharge_daily_summary
WHERE tenant_id = ?
AND summary_date >= ?
AND summary_date <= ?
UNION ALL
SELECT CONCAT('manual:', CAST(id AS CHAR)) AS id,
DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
COALESCE(NULLIF(customer_name, ''), '未关联客户') AS customerName,
fee_total AS amount,
recharge_pay_method AS payMethod,
record_source AS source,
1 AS sourceRecordCount,
DATE_FORMAT(update_time, '%Y-%m-%d %H:%i:%s') AS updatedAt
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND tenant_id = ?
AND record_source = ?
AND ${HYDROGEN_LOCAL} >= ?
AND ${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
) externalReceipts
ORDER BY date DESC, customerName ASC, id ASC`, [HYDROGEN_TENANT_ID, start, end, HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
return rows;
}
// ---------------------------------------------------------------------------
// 氢能 v2(/h2/v2/*)
// ---------------------------------------------------------------------------
export type RegionGranularity = 'province' | 'city';
/** 区域筛选所需的最小输入(与路由的 Filter 结构兼容)。 */
export interface RegionFilter {
clauses: string[];
params: unknown[];
region: string | null;
regionGranularity: RegionGranularity;
}
export function stationMasterRegionSql(granularity: RegionGranularity) {
const districtColumn = granularity === "province" ? "rs.province" : "rs.city";
const stationName = "COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_name, ''), '')";
const fallback =
granularity === "province"
? `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '浙江省'
WHEN ${stationName} LIKE '%广州%' OR ${stationName} LIKE '%佛山%' THEN '广东省'
WHEN ${stationName} LIKE '%成都%' THEN '四川省'
WHEN ${stationName} LIKE '%昆山%' THEN '江苏省'
ELSE '未归属省份' END`
: `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '嘉兴市'
WHEN ${stationName} LIKE '%广州%' THEN '广州市'
WHEN ${stationName} LIKE '%佛山%' THEN '佛山市'
WHEN ${stationName} LIKE '%成都%' THEN '成都市'
WHEN ${stationName} LIKE '%昆山%' THEN '昆山市'
ELSE '未归属区域' END`;
return `COALESCE(NULLIF(rd.NAME, ''), ${fallback})`;
}
export async function resolvedWhere(
db: Database,
filter: RegionFilter,
) {
const clauses = [...filter.clauses];
const params = [...filter.params];
if (filter.region) {
// Resolve the small station master once. The old implementation ran two
// correlated station/district subqueries for every ledger row and repeated
// that work in summary, grouping and record queries.
const districtColumn =
filter.regionGranularity === "province" ? "rs.province" : "rs.city";
const [rows] = await db.query<RowDataPacket[]>(
`SELECT CAST(rs.id AS CHAR) AS id
FROM new_hydrogen_site rs
LEFT JOIN common_district rd
ON CONVERT(rd.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(${districtColumn} USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND rd.STATUS = 'VALID'
WHERE rs.del_flag = '0'
AND ${stationMasterRegionSql(filter.regionGranularity)} = ?`,
[filter.region],
);
const stationIds = [...new Set(rows.map((row) => String(row.id)))];
if (stationIds.length === 0) {
clauses.push("1 = 0");
} else {
clauses.push(`b.station_id IN (${stationIds.map(() => "?").join(", ")})`);
params.push(...stationIds);
}
}
return { sql: clauses.join(" AND "), params };
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadMetaYears(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT YEAR(${HYDROGEN_LOCAL}) AS value,
DATE_FORMAT(MIN(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS startDate,
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS endDate
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?
GROUP BY YEAR(${HYDROGEN_LOCAL})
ORDER BY value DESC`, [HYDROGEN_MIN_DATE]);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadMetaStations(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT CAST(s.id AS CHAR) AS id,
COALESCE(NULLIF(s.site_short_name, ''), NULLIF(s.site_name, ''), CONCAT('站点 #', s.id)) AS name,
COALESCE(NULLIF(p.NAME, ''), '未归属') AS province,
COALESCE(NULLIF(ct.NAME, ''), '未归属') AS city
FROM new_hydrogen_site s
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
WHERE s.del_flag = '0'
AND EXISTS (
SELECT 1
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
AND b.station_id = s.id
AND COALESCE(b.amount_kg, 0) > 0
)
ORDER BY name`, []);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadMetaWatermark(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?`, [HYDROGEN_MIN_DATE]);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadOverviewSummary(db: Database, args: { sqlWhere: string; params: unknown[]; month: string; endDate: string }): Promise<RowDataPacket[]> {
const { sqlWhere, params, month, endDate } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS totalCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS totalRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS monthKg,
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.cost_total ELSE 0 END), 0), 2) AS monthCost,
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS todayKg,
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.cost_total ELSE 0 END), 0), 2) AS todayCost,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}`, [month, month, endDate, endDate, ...params]);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadOverviewMonthly(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m')
ORDER BY month`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadOverviewStations(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province,
COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount
FROM ${HYDROGEN_TABLE} b
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
WHERE ${sqlWhere}
GROUP BY COALESCE(b.station_id, 0)
HAVING SUM(COALESCE(b.amount_kg, 0)) > 0
ORDER BY kg DESC`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadOverviewCustomers(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}
GROUP BY COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
ORDER BY kg DESC
LIMIT 200`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDailyRows(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDailyWatermark(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
FROM ${HYDROGEN_TABLE} b WHERE ${sqlWhere}`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDailyTreeStations(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${sqlWhere}
GROUP BY COALESCE(b.station_id, 0)
ORDER BY kg DESC`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDailyTreeCustomers(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS stationId,
COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}
GROUP BY COALESCE(b.station_id, 0), COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
ORDER BY stationId, kg DESC`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDrillSummary(db: Database, args: { scopedWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { scopedWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
SUM(CASE WHEN NULLIF(b.record_source, '') IS NOT NULL OR b.source IS NOT NULL THEN 1 ELSE 0 END) AS traceableRecordCount,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDrillGroups(db: Database, args: { scopedWhere: string; params: unknown[]; groupSelect: string; groupExpression: string; groupHaving: string; groupOrder: string; pageSize: number; offset: number }): Promise<RowDataPacket[]> {
const { scopedWhere, params, groupSelect, groupExpression, groupHaving, groupOrder, pageSize, offset } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT ${groupSelect},
GROUP_CONCAT(DISTINCT COALESCE(CAST(b.settlement_type AS CHAR), 'unknown') ORDER BY COALESCE(CAST(b.settlement_type AS CHAR), 'unknown')) AS settlementTypes,
COUNT(*) AS recordCount, COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg, ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
WHERE ${scopedWhere}
GROUP BY ${groupExpression}
${groupHaving}
ORDER BY ${groupOrder} LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDrillRecords(db: Database, args: { scopedWhere: string; params: unknown[]; pageSize: number; offset: number }): Promise<RowDataPacket[]> {
const { scopedWhere, params, pageSize, offset } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT b.id, b.settlement_type AS settlementType, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS time, b.order_no AS orderNo,
CAST(COALESCE(b.station_id, 0) AS CHAR) AS stationId, COALESCE(NULLIF(b.station_name, ''), '未关联站点') AS stationName,
COALESCE(b.system_customer_id, b.customer_id, 0) AS customerId, COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS customerName,
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS plateNo, COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus, b.vehicle_id AS vehicleId,
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg, ROUND(COALESCE(b.cost_price, 0), 2) AS unitPrice,
ROUND(COALESCE(b.cost_total, 0), 2) AS cost, ROUND(COALESCE(b.fee_total, 0), 2) AS revenue
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}
ORDER BY b.${HYDROGEN_LOCAL} DESC, b.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
return rows;
}
/** 下钻的金额口径片段:在基础 WHERE 之上追加承担方条件。 */
export function buildScopedWhere(sqlWhere: string, amountScope: string): string {
if (amountScope === 'customer') return `${sqlWhere} AND ${CUSTOMER_BEARING_ORDER}`;
if (amountScope === 'company') return `${sqlWhere} AND ${COMPANY_BEARING_ORDER}`;
if (amountScope === 'other') return `${sqlWhere} AND ${OTHER_BEARING_ORDER}`;
return sqlWhere;
}
/** 下钻分组列(站点 / 客户 / 日期 / 车牌)。 */
export function buildGroupSelect(groupBy: string): string {
if (groupBy === 'station') return "CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id, COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name, COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province, COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city";
if (groupBy === 'customer') return "COALESCE(b.system_customer_id, b.customer_id, 0) AS id, COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name, NULL AS province, NULL AS city";
if (groupBy === 'date') return `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS name, NULL AS province, NULL AS city`;
return "COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS id, COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS name, NULL AS province, NULL AS city";
}
/** 下钻分组表达式(与 buildGroupSelect 必须成对使用)。 */
export function buildGroupExpression(groupBy: string): string {
if (groupBy === 'station') return 'COALESCE(b.station_id, 0)';
if (groupBy === 'customer') return "COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')";
if (groupBy === 'date') return `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`;
return "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
}
/** 由筛选条件构建 WHERE 片段与参数(顺序即 SQL 参数顺序,由契约测试锁定)。 */
export function buildFilterClauses(input: {
startDate: string;
endDate: string;
stationId: string | null;
customerId: number | null;
customerName: string | null;
plateNo: string | null;
vehicleScope: string;
verifyScope: string;
}): { clauses: string[]; params: unknown[] } {
const { startDate: safeStart, endDate: safeEnd, stationId, customerId, customerName, plateNo, vehicleScope, verifyScope } = input;
const clauses = [
HYDROGEN_FUEL_ONLY_WHERE_B,
`b.${HYDROGEN_LOCAL} >= ?`,
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
];
const params: unknown[] = [
safeStart < HYDROGEN_MIN_DATE ? HYDROGEN_MIN_DATE : safeStart,
safeEnd,
];
if (stationId !== null) {
clauses.push("COALESCE(b.station_id, 0) = ?");
params.push(stationId);
}
if (customerId !== null) {
clauses.push("COALESCE(b.system_customer_id, b.customer_id, 0) = ?");
params.push(customerId);
}
if (customerName) {
clauses.push(
"COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') = ?",
);
params.push(customerName);
}
if (plateNo) {
clauses.push("COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?");
params.push(plateNo);
}
if (vehicleScope === "lingniu") clauses.push("b.vehicle_id IS NOT NULL");
if (vehicleScope === "external") clauses.push("b.vehicle_id IS NULL");
if (verifyScope === "verified")
clauses.push(
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'",
);
if (verifyScope === "unverified")
clauses.push(
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) <> 'verified'",
// 待核对只统计核对上线期初(含当日)之后的订单,期初之前的历史订单不算待核对。
`b.refuel_time >= '${HYDROGEN_VERIFY_START_DATE}'`,
// 无车牌的订单没有核对对象;未关联羚牛车辆(vehicle_id 为 NULL 或 0)的订单
// 无法与车辆库对账,两者都不进入待核对范围。
"NULLIF(TRIM(b.license_plate), '') IS NOT NULL",
"COALESCE(b.vehicle_id, 0) <> 0",
);
return { clauses, params };
}
+741 -95
View File
@@ -1,21 +1,26 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import test from 'node:test';
import { Hono } from 'hono';
import app from './index.js';
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { Hono } from "hono";
import app from "./index.js";
import {
registerElectricMonthlyRoute,
registerElectricOverviewRoute,
type ElectricDependencies,
} from './electric.js';
} from "./electric.js";
import {
registerHydrogenDailyRoute,
type HydrogenDailyDependencies,
} from './hydrogen-daily.js';
registerEtcOverviewRoute,
type EtcOverviewDependencies,
} from "./etc.js";
import {
registerHydrogenOverviewRoute,
type HydrogenOverviewDependencies,
} from './hydrogen-overview.js';
registerHydrogenStationBoardRoute,
type HydrogenStationBoardDependencies,
} from "./hydrogen-station-board.js";
import {
registerHydrogenBiV2Routes,
type HydrogenBiV2Dependencies,
} from "./hydrogen-bi-v2.js";
import { HYDROGEN_VERIFY_START_DATE } from "./constants.js";
interface QueryCall {
sql: string;
@@ -27,6 +32,40 @@ interface CacheCall {
force: boolean | undefined;
}
test("待核对的汇总和下钻统一从核对上线期初起统计,不影响其他核对范围", async () => {
// 期初日期以字面量锁定:它同时被前端文案引用,改动必须是有意的。
assert.equal(HYDROGEN_VERIFY_START_DATE, "2026-01-01");
const unverifiedOnlyClauses = [
// 期初之前的订单不算待核对。
`b.refuel_time >= '${HYDROGEN_VERIFY_START_DATE}'`,
// 无车牌、或未关联羚牛车辆(vehicle_id 为 NULL/0)的订单没有核对对象。
"NULLIF(TRIM(b.license_plate), '') IS NOT NULL",
"COALESCE(b.vehicle_id, 0) <> 0",
];
for (const scope of ["unverified", "all", "verified"]) {
for (const endpoint of ["overview", "drill"]) {
const calls: QueryCall[] = [];
const app = new Hono();
registerHydrogenBiV2Routes(app, {
hydrogenPool: { query: createQueryMock([], calls) },
} as unknown as HydrogenBiV2Dependencies);
const response = await app.request(`/h2/v2/${endpoint}?year=2026&verifyScope=${scope}`);
assert.equal(response.status, 200);
const ledgerQueries = calls.filter(({ sql }) => sql.includes("FROM hydrogen_fuel_ledger b"));
assert.ok(ledgerQueries.length > 0);
for (const { sql } of ledgerQueries) {
for (const clause of unverifiedOnlyClauses) {
assert.equal(
sql.includes(clause),
scope === "unverified",
`${endpoint}/${scope} 对待核对限定条件「${clause}」的处理不符合预期`,
);
}
}
}
}
});
function createQueryMock(resultSets: unknown[][], calls: QueryCall[]) {
return async (sql: string, params?: unknown) => {
calls.push({ sql, params });
@@ -46,65 +85,647 @@ function createCachedMock(calls: CacheCall[]) {
}
function sqlHash(sql: string): string {
return createHash('sha256').update(sql).digest('hex').slice(0, 12);
return createHash("sha256").update(sql).digest("hex").slice(0, 12);
}
test('能源路由继续按原顺序注册四个 GET 接口', () => {
test("能源路由按既有顺序注册并增加只读单站看板接口", () => {
assert.deepEqual(
app.routes.filter(route => route.method === 'GET').map(route => route.path),
app.routes
.filter((route) => route.method === "GET")
.map((route) => route.path),
[
'/hydrogen/overview',
'/hydrogen/daily',
'/electric/overview',
'/electric/monthly',
"/hydrogen/station-board",
"/h2/v2/meta",
"/h2/v2/overview",
"/h2/v2/daily",
"/h2/v2/daily-tree",
"/h2/v2/drill",
"/electric/overview",
"/electric/monthly",
"/etc/overview",
],
);
});
test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', async () => {
const hydrogenCalls: QueryCall[] = [];
test("氢能 BI v2 使用独立真实账本合同,并且不让前端拼接旧接口", async () => {
const calls: QueryCall[] = [];
const v2App = new Hono();
registerHydrogenBiV2Routes(v2App, {
hydrogenPool: {
query: createQueryMock(
[
[{ value: 2026, startDate: "2026-01-01", endDate: "2026-08-18" }],
[{ id: 7, name: "测试站", province: "广东省", city: "佛山市" }],
[{ ledgerAt: "2026-08-18 10:00:00" }],
],
calls,
),
},
} as unknown as HydrogenBiV2Dependencies);
const response = await v2App.request("/h2/v2/meta");
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
years: [{ value: 2026, startDate: "2026-01-01", endDate: "2026-08-18" }],
stations: [{ id: "7", name: "测试站", province: "广东省", city: "佛山市" }],
watermark: { ledgerAt: "2026-08-18 10:00:00", paymentAt: null },
});
assert.equal(calls.length, 3);
assert.match(calls[0].sql, /FROM hydrogen_fuel_ledger/);
assert.match(calls[1].sql, /FROM new_hydrogen_site/);
assert.match(calls[1].sql, /EXISTS\s*\([\s\S]*amount_kg[\s\S]*> 0/);
// 所有真实下钻共用此站点选项池:历史加氢量为正,而非仅当前区间活跃。
assert.match(calls[1].sql, /b\.station_id = s\.id/);
assert.match(calls[1].sql, /external_recharge_manual/);
assert.doesNotMatch(calls[1].sql, /refuel_time\s*[<>]/);
});
test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文中复用同一过滤口径", async () => {
const calls: QueryCall[] = [];
const v2App = new Hono();
registerHydrogenBiV2Routes(v2App, {
hydrogenPool: {
query: createQueryMock(
[
[{ id: "123" }],
[{ recordCount: 2, kg: 30, cost: 900, revenue: 1000 }],
[
{
id: "2026-08-18",
name: "2026-08-18",
province: null,
city: null,
recordCount: 2,
stationCount: 1,
customerCount: 1,
kg: 30,
cost: 900,
revenue: 1000,
lingniuKg: 0,
externalKg: 30,
},
],
],
calls,
),
},
} as unknown as HydrogenBiV2Dependencies);
const response = await v2App.request(
"/h2/v2/drill?date=2026-08-18&stationId=0&customerId=0&customerName=%E6%9C%AA%E5%85%B3%E8%81%94%E5%AE%A2%E6%88%B7&plateNo=%E6%97%A0%E8%BD%A6%E7%89%8C&region=%E5%98%89%E5%85%B4%E5%B8%82&regionGranularity=city&vehicleScope=external&verifyScope=verified&groupBy=date&pageSize=20",
);
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.groupBy, "date");
assert.equal(payload.filters.date, "2026-08-18");
assert.equal(payload.filters.stationId, "0");
assert.equal(payload.filters.customerId, 0);
assert.equal(payload.filters.customerName, "未关联客户");
assert.equal(payload.filters.plateNo, "无车牌");
assert.equal(payload.filters.region, "嘉兴市");
assert.equal(payload.groups[0].id, "2026-08-18");
assert.equal(payload.summary.kg, 30);
assert.equal(calls.length, 3);
assert.deepEqual(calls[0].params, ["嘉兴市"]);
assert.match(calls[0].sql, /FROM new_hydrogen_site rs/);
const commonParams = [
"2026-08-18",
"2026-08-18",
"0",
0,
"未关联客户",
"无车牌",
"123",
];
assert.deepEqual(calls[1].params, commonParams);
assert.deepEqual((calls[2].params as unknown[]).slice(0, -2), commonParams);
for (const call of calls.slice(1)) {
assert.match(call.sql, /COALESCE\(b\.station_id, 0\) = \?/);
assert.match(
call.sql,
/COALESCE\(b\.system_customer_id, b\.customer_id, 0\) = \?/,
);
assert.match(call.sql, /COALESCE\(NULLIF\(b\.license_plate/);
assert.match(call.sql, /b\.station_id IN \(\?\)/);
assert.match(call.sql, /b\.vehicle_id IS NULL/);
assert.match(call.sql, /verify_status/);
}
assert.match(
calls[2].sql,
/GROUP BY DATE_FORMAT\(b\.refuel_time, '%Y-%m-%d'\)/,
);
assert.match(calls[2].sql, /ORDER BY id DESC/);
});
test("氢能 BI v2 利润下钻只保留客户承担订单,并与对客总价使用同一订单集合", async () => {
const calls: QueryCall[] = [];
const v2App = new Hono();
registerHydrogenBiV2Routes(v2App, {
hydrogenPool: {
query: createQueryMock(
[
[{ recordCount: 1, kg: 10, cost: 300, revenue: 360 }],
[
{
id: "7",
name: "客户承担站",
province: "浙江省",
city: "嘉兴市",
recordCount: 1,
stationCount: 1,
customerCount: 1,
kg: 10,
cost: 300,
revenue: 360,
lingniuKg: 10,
externalKg: 0,
},
],
[
{
id: 1,
time: "2026-08-18 09:00:00",
orderNo: "H2-1",
stationId: 7,
stationName: "客户承担站",
customerId: 8,
customerName: "客户A",
plateNo: "浙A1",
source: "api",
verifyStatus: "VERIFIED",
vehicleId: 1,
kg: 10,
unitPrice: 30,
cost: 300,
revenue: 360,
},
],
],
calls,
),
},
} as unknown as HydrogenBiV2Dependencies);
const response = await v2App.request(
"/h2/v2/drill?year=2026&vehicleScope=all&verifyScope=all&groupBy=station&amountScope=customer&pageSize=1&page=2",
);
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.amountScope, "customer");
assert.equal(payload.page.hasMore, true);
assert.deepEqual((calls[1].params as unknown[]).slice(-2), [1, 1]);
assert.equal(payload.summary.revenue - payload.summary.cost, 60);
assert.equal(calls.length, 2);
assert.match(calls[1].sql, /ORDER BY kg DESC, id ASC, name ASC LIMIT \? OFFSET \?/);
for (const call of calls) {
assert.match(
call.sql,
/b\.settlement_type = 1/,
);
}
});
test("氢能 BI v2 保留原型允许选择的 2023 历史年份,不回退到当前年", async () => {
const calls: QueryCall[] = [];
const v2App = new Hono();
registerHydrogenBiV2Routes(v2App, {
hydrogenPool: {
query: createQueryMock(
[
[
{
ledgerAt: null,
totalKg: 0,
totalCost: 0,
totalRevenue: 0,
customerCost: 0,
monthKg: 0,
monthCost: 0,
todayKg: 0,
todayCost: 0,
recordCount: 0,
stationCount: 0,
},
],
[],
[],
[],
],
calls,
),
},
} as unknown as HydrogenBiV2Dependencies);
const response = await v2App.request(
"/h2/v2/overview?year=2023&vehicleScope=all&verifyScope=all",
);
assert.equal(response.status, 200);
const payload = await response.json();
assert.deepEqual(payload.range, {
startDate: "2023-01-01",
endDate: "2023-12-31",
});
assert.equal(payload.filters.startDate, "2023-01-01");
assert.equal(payload.filters.endDate, "2023-12-31");
assert.equal(payload.kpis.totalKg, 0);
assert.equal(calls.length, 4);
});
test("氢能 BI v2 客户账单按 settlement_type 映射客户、我司和其他承担", async () => {
const calls: QueryCall[] = [];
const v2App = new Hono();
registerHydrogenBiV2Routes(v2App, {
hydrogenPool: {
query: createQueryMock(
[
[
{
ledgerAt: "2026-08-19 10:00:00",
totalKg: 60,
totalCost: 1800,
totalRevenue: 720,
customerBearingKg: 10,
companyBearingKg: 30,
otherBearingKg: 20,
customerRevenue: 360,
customerCost: 600,
companyCost: 900,
otherCost: 300,
monthKg: 60,
monthCost: 1800,
todayKg: 0,
todayCost: 0,
recordCount: 3,
stationCount: 1,
},
],
[
{
month: "2026-01",
totalKg: 60,
lingniuKg: 30,
externalKg: 30,
cost: 1800,
revenue: 720,
customerRevenue: 360,
customerCost: 600,
},
],
[],
[
{
id: 1,
name: "混合承担客户",
kg: 30,
customerBearingKg: 10,
companyBearingKg: 20,
otherBearingKg: 0,
cost: 900,
revenue: 300,
customerRevenue: 360,
customerCost: 600,
companyCost: 300,
otherCost: 0,
recordCount: 2,
},
{
id: 2,
name: "仅我司承担客户",
kg: 30,
customerBearingKg: 0,
companyBearingKg: 10,
otherBearingKg: 20,
cost: 900,
revenue: 0,
customerRevenue: 0,
customerCost: 0,
companyCost: 300,
otherCost: 600,
recordCount: 1,
},
],
],
calls,
),
},
} as unknown as HydrogenBiV2Dependencies);
const response = await v2App.request(
"/h2/v2/overview?year=2026&vehicleScope=all&verifyScope=all",
);
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.kpis.customerBearingKg, 10);
assert.equal(payload.kpis.companyBearingKg, 30);
assert.equal(payload.kpis.otherBearingKg, 20);
assert.equal(
payload.kpis.totalKg,
payload.kpis.customerBearingKg +
payload.kpis.companyBearingKg +
payload.kpis.otherBearingKg,
);
assert.equal(payload.kpis.customerGrossProfit, -240);
assert.equal(payload.monthly[0].customerRevenue, 360);
assert.equal(payload.monthly[0].customerCost, 600);
assert.equal(payload.monthly[0].customerGrossProfit, -240);
assert.deepEqual(payload.customers.map((row: { name: string; bearer: string }) => ({
name: row.name,
bearer: row.bearer,
})), [
{ name: "混合承担客户", bearer: "both" },
{ name: "仅我司承担客户", bearer: "other" },
]);
assert.match(calls[3].sql, /AS customerBearingKg/);
assert.match(calls[3].sql, /AS companyBearingKg/);
assert.match(calls[3].sql, /AS otherBearingKg/);
assert.match(calls[2].sql, /AS customerRevenue/);
assert.match(calls[2].sql, /AS customerCost/);
assert.match(calls[2].sql, /AS companyCost/);
assert.match(calls[2].sql, /AS otherCost/);
assert.match(calls[3].sql, /AS customerRevenue/);
assert.match(calls[3].sql, /AS customerCost/);
assert.match(calls[3].sql, /AS companyCost/);
assert.match(calls[3].sql, /AS otherCost/);
assert.equal(payload.customers[0].customerRevenue, 360);
assert.equal(payload.customers[0].customerCost, 600);
assert.match(calls[3].sql, /b\.settlement_type = 1/);
assert.match(calls[3].sql, /b\.settlement_type = 2/);
assert.match(calls[3].sql, /b\.settlement_type NOT IN \(1, 2\)/);
});
test("氢能 BI v2 月度上下文换算为完整自然月并沿用至明细", async () => {
const calls: QueryCall[] = [];
const v2App = new Hono();
registerHydrogenBiV2Routes(v2App, {
hydrogenPool: {
query: createQueryMock(
[[{ recordCount: 0, kg: 0, cost: 0, revenue: 0 }], []],
calls,
),
},
} as unknown as HydrogenBiV2Dependencies);
const response = await v2App.request(
"/h2/v2/drill?month=2026-02&vehicleScope=all&verifyScope=all&groupBy=record",
);
assert.equal(response.status, 200);
const payload = await response.json();
assert.deepEqual(payload.filters.startDate, "2026-02-01");
assert.deepEqual(payload.filters.endDate, "2026-02-28");
assert.equal(payload.filters.month, "2026-02");
assert.equal(calls.length, 2);
assert.deepEqual(calls[0].params, ["2026-02-01", "2026-02-28"]);
assert.deepEqual((calls[1].params as unknown[]).slice(0, -2), [
"2026-02-01",
"2026-02-28",
]);
});
test("指定单站即使无记录仍可选择,不伪造加氢量", async () => {
const app = new Hono();
registerHydrogenStationBoardRoute(app, {
hydrogenPool: { query: createQueryMock([
[
{ id: 341, name: "佛山南海羚牛加氢站", kg: 0, fee: 0, recordCount: 0 },
{ id: 342, name: "广州交投东鹏大道加氢站", kg: 0, fee: 0, recordCount: 0 },
{ id: 343, name: "其他零业务站", kg: 0, fee: 0, recordCount: 0 },
], [], [], [], [],
], []) },
cached: createCachedMock([]),
} as unknown as HydrogenStationBoardDependencies);
const response = await app.request("/hydrogen/station-board?startDate=2026-08-16&endDate=2026-08-17");
assert.equal(response.status, 200);
const payload = await response.json();
assert.deepEqual(payload.stations.map((station: { id: number }) => station.id), [341, 342]);
assert.equal(payload.summary.totalKg, 0);
assert.equal(payload.summary.activeStationCount, 0);
assert.ok(payload.stations.every((station: { dailyKg: { kg: number }[] }) => station.dailyKg.every(row => row.kg === 0)));
});
test("单站看板过滤非指定零业务站并合并区间现结流水", async () => {
const calls: QueryCall[] = [];
const cacheCalls: CacheCall[] = [];
const stationBoardApp = new Hono();
registerHydrogenStationBoardRoute(stationBoardApp, {
hydrogenPool: {
query: createQueryMock(
[
[
{
id: 7,
name: "测试加氢站",
province: "广东省",
city: "佛山市",
kg: 100,
fee: 3500,
recordCount: 2,
latestLedgerTime: "2026-08-17 10:00:00",
},
{
id: 8,
name: "零业务站",
province: "广东省",
city: "广州市",
kg: 0,
fee: 0,
recordCount: 0,
latestLedgerTime: null,
},
],
[
{
stationId: 7,
amount: 5000,
paymentCount: 1,
latestPaymentDate: "2026-08-17",
},
],
[
{ date: "2026-08-16", kg: 40, fee: 1400, recordCount: 1 },
{ date: "2026-08-17", kg: 60, fee: 2100, recordCount: 1 },
],
[
{ stationId: 7, date: "2026-08-16", kg: 40 },
{ stationId: 7, date: "2026-08-17", kg: 60 },
],
[{ date: "2026-08-17", amount: 5000, paymentCount: 1 }],
],
calls,
),
},
cached: createCachedMock(cacheCalls),
} as unknown as HydrogenStationBoardDependencies);
const response = await stationBoardApp.request(
"/hydrogen/station-board?startDate=2026-08-16&endDate=2026-08-17&force=1",
);
assert.equal(response.status, 200);
const payload = await response.json();
assert.deepEqual(payload.summary, {
stationCount: 1,
activeStationCount: 1,
totalKg: 100,
totalFee: 3500,
recordCount: 2,
paymentAmount: 5000,
paymentCount: 1,
latestLedgerTime: "2026-08-17 10:00:00",
daily: [
{
date: "2026-08-16",
kg: 40,
fee: 1400,
recordCount: 1,
paymentAmount: 0,
paymentCount: 0,
},
{
date: "2026-08-17",
kg: 60,
fee: 2100,
recordCount: 1,
paymentAmount: 5000,
paymentCount: 1,
},
],
});
assert.equal(payload.stations.length, 1);
assert.equal(payload.stations[0].name, "测试加氢站");
assert.deepEqual(payload.stations[0].dailyKg, [
{ date: "2026-08-16", kg: 40 },
{ date: "2026-08-17", kg: 60 },
]);
assert.deepEqual(cacheCalls, [
{
key: "hydrogen/station-board?start=2026-08-16&end=2026-08-17",
force: true,
},
]);
assert.equal(calls.length, 5);
assert.match(calls[0].sql, /FROM hydrogen_station s/);
assert.match(calls[1].sql, /FROM hydrogen_station_payment/);
assert.match(calls[2].sql, /SUM\(b\.amount_kg\)/);
assert.match(calls[3].sql, /GROUP BY b\.station_id/);
assert.match(calls[4].sql, /GROUP BY DATE_FORMAT\(payment_date/);
});
test("单站外部客户按唯一标准化车牌聚合,进账保持租户客户级口径", async () => {
const calls: QueryCall[] = [];
const stationBoardApp = new Hono();
registerHydrogenStationBoardRoute(stationBoardApp, {
hydrogenPool: {
query: createQueryMock(
[
[{ id: 341, name: "佛山南海羚牛加氢站", province: "广东省", city: "佛山市", kg: 10, fee: 300, recordCount: 1 }],
[], [], [], [],
[], [], [],
[
{
month: "2026-09",
customerName: "外部客户 A",
kg: null,
fee: null,
recordCount: 1,
},
],
[
{
id: "auto:9007199254740993",
date: "2026-09-05",
customerName: "外部客户 A",
amount: 200,
payMethod: "wechat",
source: "spot_daily_auto",
sourceRecordCount: 2,
updatedAt: "2026-09-05 23:59:00",
},
{
id: "manual:1",
date: "2026-09-04",
customerName: "外部客户 B",
amount: 0,
payMethod: "corporate",
source: "external_recharge_manual",
sourceRecordCount: 1,
updatedAt: null,
},
],
],
calls,
),
},
cached: createCachedMock([]),
} as unknown as HydrogenStationBoardDependencies);
const response = await stationBoardApp.request(
"/hydrogen/station-board?stationId=341&startDate=2026-09-01&endDate=2026-09-05",
);
assert.equal(response.status, 200);
const payload = await response.json();
assert.deepEqual(payload.selected.externalCustomerMonths, [{
month: "2026-09", customerName: "外部客户 A", kg: 0, fee: 0, recordCount: 1,
}]);
assert.deepEqual(payload.selected.externalReceipts, {
scope: "customer",
reason: "当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。",
rows: [
{
id: "auto:9007199254740993", date: "2026-09-05", customerName: "外部客户 A", amount: 200,
payMethod: "wechat", source: "spot_daily_auto", sourceRecordCount: 2, updatedAt: "2026-09-05 23:59:00",
},
{
id: "manual:1", date: "2026-09-04", customerName: "外部客户 B", amount: 0,
payMethod: "corporate", source: "external_recharge_manual", sourceRecordCount: 1, updatedAt: null,
},
],
});
assert.equal(calls.length, 10);
for (const index of [0, 2, 3, 5, 7, 8, 9]) {
assert.match(calls[index].sql, /tenant_id\s*=\s*\?/);
assert.ok((calls[index].params as unknown[]).includes("000000"));
}
for (const index of [0, 2, 3, 5, 7, 8]) {
assert.match(calls[index].sql, /record_source, ''\) <> \?/);
assert.ok((calls[index].params as unknown[]).includes("external_recharge_manual"));
}
assert.match(calls[8].sql, /customerCount = 1/);
// 自营站客户月度保留全部加氢事实:映射仅做补充,不过滤未匹配或内部车辆。
assert.match(calls[7].sql, /LEFT JOIN external_vehicle_map/);
assert.match(calls[7].sql, /COALESCE\(m.customerName/);
assert.doesNotMatch(calls[7].sql.split('WHERE b.del_flag')[1], /vehicle_id IS NULL/);
assert.match(calls[7].sql, /GROUP BY[\s\S]*customerName/);
assert.match(calls[8].sql, /CONVERT\(UPPER\(REPLACE/);
assert.match(calls[8].sql, /b\.vehicle_id IS NULL OR b\.vehicle_id = 0/);
assert.match(calls[9].sql, /UNION ALL/);
assert.match(calls[9].sql, /fee_total AS amount/);
assert.match(calls[9].sql, /summary_date >= \?/);
assert.match(calls[9].sql, /refuel_time < DATE_ADD\(\?, INTERVAL 1 DAY\)/);
});
test("电能处理器继续使用原缓存语义、数据库、SQL 与参数", async () => {
const electricCalls: QueryCall[] = [];
const cacheCalls: CacheCall[] = [];
const cached = createCachedMock(cacheCalls);
const hydrogenQuery = createQueryMock([
[{ y: 2025 }],
[{}],
[],
[],
[],
[],
[],
const electricQuery = createQueryMock(
[
{ d: '2025-01-02', stationId: 7, stationName: '测试站', kg: 10, pricePerKg: 35 },
[
{
totalKwh: 20,
totalFee: 30,
monthKwh: 15,
monthFee: 25,
todayKwh: 10,
todayFee: 12,
},
],
[],
[{ date: "2025-01-02", kwh: 10, fee: 12 }],
[{ kwh: 5 }],
[{ date: "2025-01-02", kwh: 10, fee: 12 }],
],
], hydrogenCalls);
const electricQuery = createQueryMock([
[{ totalKwh: 20, totalFee: 30, monthKwh: 15, monthFee: 25, todayKwh: 10, todayFee: 12 }],
[],
[{ date: '2025-01-02', kwh: 10, fee: 12 }],
[{ kwh: 5 }],
[{ date: '2025-01-02', kwh: 10, fee: 12 }],
], electricCalls);
const hydrogenOverviewApp = new Hono();
registerHydrogenOverviewRoute(hydrogenOverviewApp, {
hydrogenPool: { query: hydrogenQuery },
cached,
} as unknown as HydrogenOverviewDependencies);
const overviewResponse = await hydrogenOverviewApp.request(
'/hydrogen/overview?year=2025&force=1',
electricCalls,
);
assert.equal(overviewResponse.status, 200);
const hydrogenDailyApp = new Hono();
registerHydrogenDailyRoute(hydrogenDailyApp, {
hydrogenPool: { query: hydrogenQuery },
cached,
} as unknown as HydrogenDailyDependencies);
const dailyResponse = await hydrogenDailyApp.request(
'/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
);
assert.equal(dailyResponse.status, 200);
const electricApp = new Hono();
const electricDependencies = {
@@ -113,55 +734,80 @@ test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', a
} as unknown as ElectricDependencies;
registerElectricOverviewRoute(electricApp, electricDependencies);
registerElectricMonthlyRoute(electricApp, electricDependencies);
const electricOverviewResponse = await electricApp.request('/electric/overview?force=1');
const electricOverviewResponse = await electricApp.request(
"/electric/overview?force=1",
);
assert.equal(electricOverviewResponse.status, 200);
const electricMonthlyResponse = await electricApp.request(
'/electric/monthly?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
"/electric/monthly?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1",
);
assert.equal(electricMonthlyResponse.status, 200);
assert.deepEqual(cacheCalls, [
{ key: 'hydrogen/overview?year=2025', force: true },
{ key: 'hydrogen/daily?start=2025-01-01&end=2025-01-02&customer=external', force: true },
{ key: 'electric/overview', force: true },
{ key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true },
{ key: "electric/overview", force: true },
{
key: "electric/monthly?customer=external&start=2025-01-01&end=2025-01-02",
force: true,
},
]);
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。
// 指纹锁定既有查询结构,避免取数口径被无意改动。
assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)),
electricCalls.map((call) => sqlHash(call.sql)),
[
'95f6d99b61c9',
'63a63c5f78bb',
'1367eddb3b7f',
'83a52a7f9336',
'f28fa4a3cb08',
'dac9e86fc527',
'a71da8f05765',
'b562668859ce',
'226fd8f950a1',
'6e4031b10926',
'4fc77463294c',
'1886d54939d8',
'09fc714ec4b2',
"03fd669f8077",
"6e4031b10926",
"4fc77463294c",
"1886d54939d8",
"09fc714ec4b2",
],
);
assert.deepEqual(hydrogenCalls.map(call => call.params), [
['2024-01-01'],
[2025, 2025, 2025, 2025, 2025, 2025, 2025, 0, 0, 0, 0, 0, 0, 0, 0, '2024-01-01'],
['2024-01-01', 2025],
['2024-01-01', 2025],
['2024-01-01', 2025],
['2024-01-01', 2025],
['2024-01-01', 2025],
['2025-01-01', '2025-01-02'],
]);
assert.deepEqual(electricCalls.map(call => call.params), [
undefined,
undefined,
undefined,
undefined,
['2025-01-01', '2025-01-02'],
]);
assert.deepEqual(
electricCalls.map((call) => call.params),
[undefined, undefined, undefined, undefined, ["2025-01-01", "2025-01-02"]],
);
});
test("ETC 总览对空台账返回明确空状态", async () => {
const calls: QueryCall[] = [];
const cacheCalls: CacheCall[] = [];
const app = new Hono();
registerEtcOverviewRoute(app, {
pool: {
query: createQueryMock(
[
[
{
tollRecordCount: 0,
vehicleCount: 0,
totalAmount: null,
latestTollTime: null,
billCount: 0,
receivableAmount: null,
paidAmount: null,
},
],
],
calls,
),
},
cached: createCachedMock(cacheCalls),
} as unknown as EtcOverviewDependencies);
const response = await app.request("/etc/overview?force=1");
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
tollRecordCount: 0,
vehicleCount: 0,
totalAmount: 0,
latestTollTime: null,
billCount: 0,
receivableAmount: 0,
paidAmount: 0,
hasData: false,
});
assert.deepEqual(cacheCalls, [{ key: "etc/overview", force: true }]);
assert.match(calls[0].sql, /FROM etc_toll_record/);
assert.match(calls[0].sql, /FROM energy_etc_bill/);
});
-190
View File
@@ -1,190 +0,0 @@
import { Hono } from 'hono';
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
import { canManageFeedback } from '../../auth/types.js';
import { uploadFeedbackImage } from './oss.js';
const app = new Hono();
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS bi_user_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
module VARCHAR(64) NULL,
content TEXT NOT NULL,
contact VARCHAR(200) NULL,
screenshots JSON NULL,
user_id VARCHAR(64) NULL,
user_name VARCHAR(128) NULL,
user_agent VARCHAR(512) NULL,
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
reply_content TEXT NULL,
reply_user VARCHAR(128) NULL,
reply_at DATETIME NULL,
created_at DATETIME NOT NULL,
KEY idx_created_at (created_at),
KEY idx_type (type),
KEY idx_status (status),
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
let ensured = false;
async function ensureTable(): Promise<void> {
if (ensured) return;
await pool.query(CREATE_TABLE_SQL);
// 兼容旧表:补齐缺失列
for (const alter of [
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
]) {
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
}
ensured = true;
}
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
app.post('/submit', async (c) => {
await ensureTable();
const body = await c.req.json().catch(() => ({})) as {
type?: string; module?: string | null; content?: string;
contact?: string | null; userAgent?: string; screenshots?: string[];
};
const type = (body.type || '').trim();
const content = (body.content || '').trim();
if (!VALID_TYPES.has(type)) {
return c.json({ ok: false, message: '类型不合法' }, 400);
}
if (!content || content.length > 2000) {
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
}
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
const moduleVal = (body.module || '').slice(0, 64) || null;
const contact = (body.contact || '').slice(0, 200) || null;
const userAgent = (body.userAgent || '').slice(0, 512) || null;
const screenshots = Array.isArray(body.screenshots)
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
: [];
const [r] = await pool.query<ResultSetHeader>(
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
);
return c.json({ ok: true, id: r.insertId });
});
// =========================================================
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
// =========================================================
app.post('/upload', async (c) => {
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const mime = file.type || 'image/png';
if (!ALLOWED_MIME.has(mime)) {
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
}
if (file.size > MAX_IMAGE_SIZE) {
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400);
}
const buf = Buffer.from(await file.arrayBuffer());
try {
const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime);
return c.json({ ok: true, url });
} catch (e) {
console.error('feedback upload error:', e);
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
}
});
// GET /api/feedback/mine — 当前用户的反馈历史
app.get('/mine', async (c) => {
await ensureTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!user?.userId) return c.json({ items: [] });
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, type, module, content, contact, screenshots, status,
reply_content, reply_user, reply_at, created_at
FROM bi_user_feedback
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 100`,
[user.userId],
);
return c.json({ items: rows });
});
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.get('/list', async (c) => {
await ensureTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
}
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
const status = c.req.query('status') || '';
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
if (VALID_STATUS.has(status)) {
where.push('status = ?');
params.push(status);
}
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
reply_content, reply_user, reply_at, created_at
FROM bi_user_feedback
WHERE ${where.join(' AND ')}
ORDER BY created_at DESC
LIMIT ?`,
[...params, limit],
);
return c.json({ items: rows });
});
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.patch('/:id', async (c) => {
await ensureTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
}
const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
const fields: string[] = [];
const params: (string | number | null)[] = [];
if (body.status) {
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
fields.push('status = ?');
params.push(body.status);
}
if (typeof body.reply === 'string') {
const reply = body.reply.trim().slice(0, 2000);
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
params.push(reply || null, user?.userName || user?.userId || null);
}
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
params.push(id);
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
return c.json({ ok: true });
});
export default app;
+104
View File
@@ -0,0 +1,104 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
/**
* 反馈表的全部 SQL 出口。
*
* SQL 与参数顺序在此固定:路由只做校验与组装,不再拼 SQL。
* 接口由 mock pool 的契约测试锁定(见 routes.test.ts)。
*/
/** 只需要 query 能力的最小依赖,便于在测试中注入 mock。 */
export interface Database {
query<T = any>(sql: string, values?: unknown[]): Promise<[T, ...any[]]>;
}
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置。
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
// 两个列表接口的列集合刻意不同:管理列表额外返回 user_id / user_name。
const USER_LIST_COLUMNS = `id, type, module, content, contact, screenshots, status,
reply_content, reply_user, reply_at, created_at`;
const ADMIN_LIST_COLUMNS = `id, type, module, content, contact, screenshots, user_id, user_name, status,
reply_content, reply_user, reply_at, created_at`;
export interface NewFeedback {
type: string;
module: string | null;
content: string;
contact: string | null;
screenshotsJson: string;
userId: string | null;
userName: string | null;
userAgent: string | null;
}
export async function insertFeedback(db: Database, row: NewFeedback): Promise<number> {
const [result] = await db.query<ResultSetHeader>(
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
[row.type, row.module, row.content, row.contact, row.screenshotsJson, row.userId, row.userName, row.userAgent],
);
return result.insertId;
}
/** 当前用户的反馈历史(最多 100 条)。 */
export async function listFeedbackForUser(db: Database, userId: string): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT ${USER_LIST_COLUMNS}
FROM bi_user_feedback
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 100`,
[userId],
);
return rows;
}
/** 管理列表:可选状态过滤 + 条数上限。 */
export async function listFeedbackForAdmin(
db: Database,
options: { status: string | null; limit: number },
): Promise<RowDataPacket[]> {
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
if (options.status) {
where.push('status = ?');
params.push(options.status);
}
const [rows] = await db.query<RowDataPacket[]>(
`SELECT ${ADMIN_LIST_COLUMNS}
FROM bi_user_feedback
WHERE ${where.join(' AND ')}
ORDER BY created_at DESC
LIMIT ?`,
[...params, options.limit],
);
return rows;
}
export interface FeedbackUpdate {
status?: string;
/** 传 undefined 表示不更新回复;传 null/'' 表示清空。 */
reply?: string | null;
replyUser?: string | null;
}
/** 管理更新。返回 false 表示没有任何可更新字段。 */
export async function updateFeedback(db: Database, id: number, update: FeedbackUpdate): Promise<boolean> {
const fields: string[] = [];
const params: (string | number | null)[] = [];
if (update.status !== undefined) {
fields.push('status = ?');
params.push(update.status);
}
if (update.reply !== undefined) {
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
params.push(update.reply || null, update.replyUser ?? null);
}
if (fields.length === 0) return false;
await db.query(
`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`,
[...params, id],
);
return true;
}
+211
View File
@@ -0,0 +1,211 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
import { Hono } from "hono";
import type { AuthUser } from "../../auth/types.js";
import { registerFeedbackRoutes, type FeedbackDependencies } from "./routes.js";
import type { Database } from "./repository.js";
interface Call {
sql: string;
params: unknown[];
}
/** 规范化空格,便于对 SQL 文本做稳定断言(不改变语义)。 */
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
const calls: Call[] = [];
const db: Database = {
async query(sql: string, params?: unknown[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return [resultSets.shift() ?? [], []] as never;
},
};
return { db, calls };
}
const ADMIN: AuthUser = {
userId: "u-admin",
userName: "管理员",
loginName: "admin",
depCode: "",
depName: "",
permissionLevel: "full",
roles: ["BI-ADMIN-FEEDBACK"],
};
const NORMAL: AuthUser = {
userId: "u-1",
userName: "普通用户",
loginName: "user1",
depCode: "",
depName: "",
permissionLevel: "personal",
roles: [],
};
function makeApp(user: AuthUser | undefined, deps: FeedbackDependencies): Hono<{ Variables: { user: AuthUser } }> {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
if (user) c.set("user", user);
await next();
});
registerFeedbackRoutes(app as never, deps);
return app;
}
test("提交反馈:INSERT 语句与参数顺序保持不变", async () => {
const { db, calls } = createMockDb([{ insertId: 42 }]);
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
const res = await app.request("/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "bug",
module: "mileage",
content: " 车牌筛选失效 ",
contact: "13800000000",
screenshots: ["https://oss.example.com/a.png", "javascript:alert(1)", "not-a-url"],
userAgent: "jest",
}),
});
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: true, id: 42 });
assert.equal(calls.length, 1);
assert.equal(
calls[0].sql,
"INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR))",
);
assert.deepEqual(calls[0].params, [
"bug",
"mileage",
"车牌筛选失效",
"13800000000",
JSON.stringify(["https://oss.example.com/a.png"]),
"u-1",
"普通用户",
"jest",
]);
});
test("提交反馈:类型与内容长度校验拦截", async () => {
const { db, calls } = createMockDb();
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
const badType = await app.request("/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "nope", content: "x" }),
});
assert.equal(badType.status, 400);
const empty = await app.request("/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "bug", content: " " }),
});
assert.equal(empty.status, 400);
assert.equal(calls.length, 0, "校验失败不应触达数据库");
});
test("我的反馈:列集合不含 user_id/user_name,按当前用户过滤", async () => {
const { db, calls } = createMockDb([[]]);
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
const res = await app.request("/mine");
assert.equal(res.status, 200);
assert.equal(calls.length, 1);
assert.equal(
calls[0].sql,
"SELECT id, type, module, content, contact, screenshots, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE user_id = ? ORDER BY created_at DESC LIMIT 100",
);
assert.deepEqual(calls[0].params, ["u-1"]);
});
test("我的反馈:无登录用户时直接返回空列表且不查库", async () => {
const { db, calls } = createMockDb();
const app = makeApp(undefined, { db, ensureTable: async () => {} });
const res = await app.request("/mine");
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { items: [] });
assert.equal(calls.length, 0);
});
test("管理列表:无管理角色返回 403 且不查库", async () => {
const { db, calls } = createMockDb();
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
const res = await app.request("/list");
assert.equal(res.status, 403);
assert.equal(calls.length, 0);
});
test("管理列表:列集合含 user_id/user_name,状态白名单与条数上限生效", async () => {
const { db, calls } = createMockDb([[]]);
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
await app.request("/list?status=done&limit=9999");
assert.equal(
calls[0].sql,
"SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE 1=1 AND status = ? ORDER BY created_at DESC LIMIT ?",
);
assert.deepEqual(calls[0].params, ["done", 500], "limit 上限为 500");
calls.length = 0;
await app.request("/list?status=not-a-status&limit=abc");
assert.equal(
calls[0].sql,
"SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE 1=1 ORDER BY created_at DESC LIMIT ?",
);
assert.deepEqual(calls[0].params, [100], "非法状态被丢弃,limit 回退默认值");
});
test("管理更新:状态与回复的字段顺序与参数顺序保持不变", async () => {
const { db, calls } = createMockDb([{ affectedRows: 1 }]);
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
const res = await app.request("/7", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "done", reply: " 已修复 " }),
});
assert.equal(res.status, 200);
assert.equal(
calls[0].sql,
"UPDATE bi_user_feedback SET status = ?, reply_content = ?, reply_user = ?, reply_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR) WHERE id = ?",
);
assert.deepEqual(calls[0].params, ["done", "已修复", "管理员", 7]);
});
test("管理更新:无可用字段或非法 id 返回 400", async () => {
const { db, calls } = createMockDb();
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
const none = await app.request("/7", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
assert.equal(none.status, 400);
const badId = await app.request("/0", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "done" }),
});
assert.equal(badId.status, 400);
assert.equal(calls.length, 0);
});
test("分层:路由文件不再直接书写 SQL", () => {
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
const offenders = source.match(/\b(SELECT|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts,路由只做校验与组装");
});
+158
View File
@@ -0,0 +1,158 @@
import { Hono } from 'hono';
import pool from '../../db/mysql.js';
import { ensureFeedbackTable } from '../../db/schema/feedback.js';
import type { AuthUser } from '../../auth/types.js';
import { canManageFeedback } from '../../auth/types.js';
import { uploadFeedbackImage } from './oss.js';
import {
insertFeedback,
listFeedbackForAdmin,
listFeedbackForUser,
updateFeedback,
type Database,
} from './repository.js';
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
export interface FeedbackDependencies {
db: Database;
/** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */
ensureTable: () => Promise<void>;
/** 截图上传实现,测试可注入。 */
uploadImage?: typeof uploadFeedbackImage;
}
function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined {
return c.get?.('user') as AuthUser | undefined;
}
export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): void {
const { db, ensureTable } = deps;
const uploadImage = deps.uploadImage ?? uploadFeedbackImage;
// POST /api/feedback/submit — 提交反馈
app.post('/submit', async (c) => {
await ensureTable();
const body = await c.req.json().catch(() => ({})) as {
type?: string; module?: string | null; content?: string;
contact?: string | null; userAgent?: string; screenshots?: string[];
};
const type = (body.type || '').trim();
const content = (body.content || '').trim();
if (!VALID_TYPES.has(type)) {
return c.json({ ok: false, message: '类型不合法' }, 400);
}
if (!content || content.length > 2000) {
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
}
const user = currentUser(c);
const moduleVal = (body.module || '').slice(0, 64) || null;
const contact = (body.contact || '').slice(0, 200) || null;
const userAgent = (body.userAgent || '').slice(0, 512) || null;
const screenshots = Array.isArray(body.screenshots)
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
: [];
const id = await insertFeedback(db, {
type,
module: moduleVal,
content,
contact,
screenshotsJson: JSON.stringify(screenshots),
userId: user?.userId || null,
userName: user?.userName || null,
userAgent,
});
return c.json({ ok: true, id });
});
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
app.post('/upload', async (c) => {
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const mime = file.type || 'image/png';
if (!ALLOWED_MIME.has(mime)) {
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
}
if (file.size > MAX_IMAGE_SIZE) {
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400);
}
const buf = Buffer.from(await file.arrayBuffer());
try {
const url = await uploadImage(file.name || 'screenshot.png', buf, mime);
return c.json({ ok: true, url });
} catch (e) {
console.error('feedback upload error:', e);
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
}
});
// GET /api/feedback/mine — 当前用户的反馈历史
app.get('/mine', async (c) => {
await ensureTable();
const user = currentUser(c);
if (!user?.userId) return c.json({ items: [] });
return c.json({ items: await listFeedbackForUser(db, user.userId) });
});
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.get('/list', async (c) => {
await ensureTable();
const user = currentUser(c);
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
}
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
const rawStatus = c.req.query('status') || '';
const status = VALID_STATUS.has(rawStatus) ? rawStatus : null;
return c.json({ items: await listFeedbackForAdmin(db, { status, limit }) });
});
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.patch('/:id', async (c) => {
await ensureTable();
const user = currentUser(c);
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
}
const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
let status: string | undefined;
if (body.status) {
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
status = body.status;
}
const reply = typeof body.reply === 'string'
? body.reply.trim().slice(0, 2000)
: undefined;
const updated = await updateFeedback(db, id, {
status,
reply,
replyUser: user?.userName || user?.userId || null,
});
if (!updated) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
return c.json({ ok: true });
});
}
/** 生产用路由器:绑定真实连接池与建表函数。 */
export function createFeedbackRouter(): Hono {
const app = new Hono();
registerFeedbackRoutes(app, { db: pool, ensureTable: ensureFeedbackTable });
return app;
}
const app = createFeedbackRouter();
export default app;
-242
View File
@@ -1,242 +0,0 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import pool from '../db.js';
import type { AuthUser } from '../auth/types.js';
import { canAccessEnergy } from '../auth/types.js';
import {
buildHydrogenPoints,
filterHydrogenStationsByRadius,
HYDROGEN_HEATMAP_DEFAULT_END,
HYDROGEN_HEATMAP_DEFAULT_START,
parseHydrogenHeatmapQuery,
parseNearbyQuery,
rankHydrogenStations,
serializeHydrogenStation,
type HydrogenPayer,
type HydrogenStationRecord,
} from './hydrogen-heatmap-model.js';
type StationRow = RowDataPacket & HydrogenStationRecord;
const VALID_COORDINATE = `
o.longitude BETWEEN 73.5 AND 135.1
AND o.latitude BETWEEN 18 AND 53.6
AND COALESCE(o.province, '') NOT LIKE '%西藏%'
`;
function buildWhere(startDate: string, endDate: string, query: string, payer: HydrogenPayer) {
const clauses = [
`b.del_flag = '0'`,
`DATE(b.refuel_time) BETWEEN ? AND ?`,
VALID_COORDINATE,
];
const params: Array<string | number> = [startDate, endDate];
if (query) {
clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`);
params.push(`%${query}%`);
}
if (payer === 'lingniu') {
clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`);
} else if (payer === 'customer') {
clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`);
}
return { sql: clauses.join(' AND '), params };
}
async function loadStations(
startDate: string,
endDate: string,
query: string,
payer: HydrogenPayer,
): Promise<StationRow[]> {
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<StationRow[]>(`
SELECT
CAST(b.station_id AS CHAR) AS station_id,
COALESCE(
NULLIF(MAX(s.station_short_name), ''),
NULLIF(MAX(s.station_name), ''),
NULLIF(MAX(b.station_name), ''),
NULLIF(MAX(o.fixed_station_name), ''),
CONCAT('未知站点 #', b.station_id)
) AS station_name,
COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address,
MAX(o.longitude) AS longitude,
MAX(o.latitude) AS latitude,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
GROUP BY b.station_id
`, where.params);
return rows;
}
const router = new Hono();
router.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
router.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503);
return c.json({ key, securityCode });
});
router.get('/meta', async (c) => {
const [profileRows, optionRows] = await Promise.all([
pool.query<RowDataPacket[]>(`
SELECT
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d') AS start_date,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d') AS end_date,
COUNT(*) AS total_refuel_count,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS total_kg,
COUNT(DISTINCT b.station_id) AS total_station_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
SUM(CASE WHEN ${VALID_COORDINATE} THEN 1 ELSE 0 END) AS eligible_refuel_count,
ROUND(SUM(CASE WHEN ${VALID_COORDINATE} THEN COALESCE(b.amount_kg, 0) ELSE 0 END), 2) AS eligible_kg,
COUNT(DISTINCT CASE WHEN ${VALID_COORDINATE} THEN b.station_id END) AS eligible_station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE b.del_flag = '0'
`),
pool.query<RowDataPacket[]>(`
SELECT
CAST(s.id AS CHAR) AS station_id,
COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name
FROM hydrogen_station s
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id
INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0'
WHERE s.del_flag = '0' AND ${VALID_COORDINATE}
GROUP BY s.id, s.station_short_name, s.station_name
ORDER BY station_name
`),
]);
const profile = profileRows[0][0] || {};
const totalRefuelCount = Number(profile.total_refuel_count) || 0;
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
return c.json({
startDate: profile.start_date || HYDROGEN_HEATMAP_DEFAULT_START,
endDate: profile.end_date || HYDROGEN_HEATMAP_DEFAULT_END,
totalRefuelCount,
eligibleRefuelCount,
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0,
totalKg: Number(profile.total_kg) || 0,
eligibleKg: Number(profile.eligible_kg) || 0,
vehicleCount: Number(profile.vehicle_count) || 0,
totalStationCount: Number(profile.total_station_count) || 0,
eligibleStationCount: Number(profile.eligible_station_count) || 0,
stations: optionRows[0].map((row) => ({
stationId: String(row.station_id),
stationName: String(row.station_name),
})),
});
});
router.get('/points', async (c) => {
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
payer: c.req.query('payer'),
metric: c.req.query('metric'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const stations = await loadStations(startDate, endDate, query, payer);
const ranked = rankHydrogenStations(stations, metric);
const { points, max } = buildHydrogenPoints(ranked, metric);
const where = buildWhere(startDate, endDate, query, payer);
const [summaryRows] = await pool.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count,
COUNT(DISTINCT DATE(b.refuel_time)) AS day_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
`, where.params);
const summary = summaryRows[0] || {};
return c.json({
startDate,
endDate,
metric,
payer,
kg: Number(summary.kg) || 0,
refuelCount: Number(summary.refuel_count) || 0,
vehicleCount: Number(summary.vehicle_count) || 0,
stationCount: Number(summary.station_count) || 0,
dayCount: Number(summary.day_count) || 0,
points,
max,
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
router.get('/nearby', async (c) => {
const { center, radiusKm } = parseNearbyQuery({
lng: c.req.query('lng'),
lat: c.req.query('lat'),
radiusKm: c.req.query('radiusKm'),
});
if (!center) return c.json({ error: '经纬度参数无效' }, 400);
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
payer: c.req.query('payer'),
metric: c.req.query('metric'),
});
const stations = await loadStations(startDate, endDate, query, payer);
const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm);
const nearbyIds = nearby.map((row) => row.station_id);
let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 };
if (nearbyIds.length) {
const placeholders = nearbyIds.map(() => '?').join(',');
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders})
`, [...where.params, ...nearbyIds]);
nearbySummary = {
kg: Number(rows[0]?.kg) || 0,
refuelCount: Number(rows[0]?.refuel_count) || 0,
vehicleCount: Number(rows[0]?.vehicle_count) || 0,
stationCount: Number(rows[0]?.station_count) || 0,
};
}
const ranked = rankHydrogenStations(nearby, metric);
return c.json({
center,
radiusKm,
kg: nearbySummary.kg,
refuelCount: nearbySummary.refuelCount,
vehicleCount: nearbySummary.vehicleCount,
stationCount: nearbySummary.stationCount,
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
export default router;
@@ -10,7 +10,12 @@ import {
rankHydrogenStations,
serializeHydrogenStation,
type HydrogenStationRecord,
} from './hydrogen-heatmap-model.js';
} from './model.js';
import { recentDayRange } from '../../../shared/date-range.js';
// 默认区间是"近 30 天"滚动窗口,注入固定时钟让断言可复现。
const NOW = new Date('2026-07-13T12:00:00');
const FALLBACK = recentDayRange(30, NOW);
function station(overrides: Partial<HydrogenStationRecord> = {}): HydrogenStationRecord {
return {
@@ -28,10 +33,10 @@ function station(overrides: Partial<HydrogenStationRecord> = {}): HydrogenStatio
};
}
test('加氢热力图查询继续使用固定默认日期、去除搜索空白并限制枚举值', () => {
assert.deepEqual(parseHydrogenHeatmapQuery({}), {
startDate: '2026-01-01',
endDate: '2026-07-13',
test('加氢热力图查询默认使用近 30 天滚动窗口、去除搜索空白并限制枚举值', () => {
assert.deepEqual(parseHydrogenHeatmapQuery({}, NOW), {
startDate: FALLBACK.start,
endDate: FALLBACK.end,
query: '',
payer: 'all',
metric: 'kg',
@@ -42,9 +47,9 @@ test('加氢热力图查询继续使用固定默认日期、去除搜索空白
query: ' 嘉兴 ',
payer: 'customer',
metric: 'vehicles',
}), {
}, NOW), {
startDate: '2026-06-01',
endDate: '2026-07-13',
endDate: FALLBACK.end,
query: '嘉兴',
payer: 'customer',
metric: 'vehicles',
@@ -1,3 +1,4 @@
import { recentDayRange } from '../../../shared/date-range.js';
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
@@ -28,8 +29,8 @@ type NearbyQueryInput = {
radiusKm?: string;
};
export const HYDROGEN_HEATMAP_DEFAULT_START = '2026-01-01';
export const HYDROGEN_HEATMAP_DEFAULT_END = '2026-07-13';
/** 未显式传日期时的兜底窗口:近 N 天滚动,不再写死历史区间。 */
export const HEATMAP_DEFAULT_WINDOW_DAYS = 30;
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
@@ -45,10 +46,11 @@ function parsePayer(value: string | undefined): HydrogenPayer {
return 'all';
}
export function parseHydrogenHeatmapQuery(input: HydrogenHeatmapQueryInput) {
export function parseHydrogenHeatmapQuery(input: HydrogenHeatmapQueryInput, now: Date = new Date()) {
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS, now);
return {
startDate: isDate(input.startDate) ? input.startDate : HYDROGEN_HEATMAP_DEFAULT_START,
endDate: isDate(input.endDate) ? input.endDate : HYDROGEN_HEATMAP_DEFAULT_END,
startDate: isDate(input.startDate) ? input.startDate : fallback.start,
endDate: isDate(input.endDate) ? input.endDate : fallback.end,
query: (input.query || '').trim(),
payer: parsePayer(input.payer),
metric: parseMetric(input.metric),
@@ -0,0 +1,157 @@
import type { RowDataPacket } from 'mysql2';
import type { HydrogenPayer, HydrogenStationRecord } from './model.js';
/**
* 加氢热力图的数据访问。
*
* 全部 SQL 集中在此;`buildWhere` 的片段顺序与参数顺序由 routes.test.ts 锁定。
* 注意 `VALID_COORDINATE` 被多处 SQL 复用(meta 的统计口径依赖它),不要各自复制。
*/
/** MySQL 连接:只声明用到的能力。 */
export interface MysqlDatabase {
query<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
export type StationRow = RowDataPacket & HydrogenStationRecord;
/** 有效坐标与省份排除条件(多处 SQL 共用,必须保持一致)。 */
const VALID_COORDINATE = `
o.longitude BETWEEN 73.5 AND 135.1
AND o.latitude BETWEEN 18 AND 53.6
AND COALESCE(o.province, '') NOT LIKE '%西藏%'
`;
/** 组装 WHERE 片段;片段顺序与参数顺序由契约测试锁定。 */
export function buildWhere(startDate: string, endDate: string, query: string, payer: HydrogenPayer) {
const clauses = [
`b.del_flag = '0'`,
`DATE(b.refuel_time) BETWEEN ? AND ?`,
VALID_COORDINATE,
];
const params: Array<string | number> = [startDate, endDate];
if (query) {
clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`);
params.push(`%${query}%`);
}
if (payer === 'lingniu') {
clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`);
} else if (payer === 'customer') {
clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`);
}
return { sql: clauses.join(' AND '), params };
}
export async function loadStations(
mysql: MysqlDatabase,
startDate: string,
endDate: string,
query: string,
payer: HydrogenPayer,
): Promise<StationRow[]> {
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await mysql.execute<StationRow[]>(`
SELECT
CAST(b.station_id AS CHAR) AS station_id,
COALESCE(
NULLIF(MAX(s.station_short_name), ''),
NULLIF(MAX(s.station_name), ''),
NULLIF(MAX(b.station_name), ''),
NULLIF(MAX(o.fixed_station_name), ''),
CONCAT('未知站点 #', b.station_id)
) AS station_name,
COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address,
MAX(o.longitude) AS longitude,
MAX(o.latitude) AS latitude,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
GROUP BY b.station_id
`, where.params);
return rows;
}
/** 数据水位与覆盖情况。 */
export async function loadMetaProfile(mysql: MysqlDatabase): Promise<RowDataPacket[]> {
const [rows] = await mysql.query<RowDataPacket[]>(`
SELECT
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d') AS start_date,
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d') AS end_date,
COUNT(*) AS total_refuel_count,
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS total_kg,
COUNT(DISTINCT b.station_id) AS total_station_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
SUM(CASE WHEN ${VALID_COORDINATE} THEN 1 ELSE 0 END) AS eligible_refuel_count,
ROUND(SUM(CASE WHEN ${VALID_COORDINATE} THEN COALESCE(b.amount_kg, 0) ELSE 0 END), 2) AS eligible_kg,
COUNT(DISTINCT CASE WHEN ${VALID_COORDINATE} THEN b.station_id END) AS eligible_station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE b.del_flag = '0'
`);
return rows;
}
/** 有有效坐标的站点选项。 */
export async function loadStationOptions(mysql: MysqlDatabase): Promise<RowDataPacket[]> {
const [rows] = await mysql.query<RowDataPacket[]>(`
SELECT
CAST(s.id AS CHAR) AS station_id,
COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name
FROM hydrogen_station s
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id
INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0'
WHERE s.del_flag = '0' AND ${VALID_COORDINATE}
GROUP BY s.id, s.station_short_name, s.station_name
ORDER BY station_name
`);
return rows;
}
/** /points 的汇总(含站点数与天数)。 */
export async function loadPointsSummary(
mysql: MysqlDatabase,
where: { sql: string; params: Array<string | number> },
): Promise<RowDataPacket[]> {
const [rows] = await mysql.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count,
COUNT(DISTINCT DATE(b.refuel_time)) AS day_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql}
`, where.params);
return rows;
}
/** /nearby 的汇总:在 WHERE 之上再按站点 id 收窄。 */
export async function loadNearbySummary(
mysql: MysqlDatabase,
where: { sql: string; params: Array<string | number> },
placeholders: string,
nearbyIds: string[],
): Promise<RowDataPacket[]> {
const [rows] = await mysql.execute<RowDataPacket[]>(`
SELECT
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
COUNT(*) AS refuel_count,
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
COUNT(DISTINCT b.station_id) AS station_count
FROM hydrogen_fuel_ledger b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders})
`, [...where.params, ...nearbyIds]);
return rows;
}
@@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
import { Hono } from "hono";
import type { AuthUser } from "../../auth/types.js";
import { registerHydrogenHeatmapRoutes, type HydrogenHeatmapDependencies } from "./routes.js";
import { buildWhere, type MysqlDatabase } from "./repository.js";
interface Call {
sql: string;
params: unknown[];
}
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
function createMysql(handler?: (sql: string) => unknown[]): { db: MysqlDatabase; calls: Call[] } {
const calls: Call[] = [];
const respond = (sql: string) => {
const rows = handler ? handler(sql) : [];
return [rows, []] as never;
};
return {
calls,
db: {
async query(sql: string, params?: any[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return respond(String(sql));
},
async execute(sql: string, params?: any[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return respond(String(sql));
},
},
};
}
const ENERGY: AuthUser = {
userId: "u", userName: "n", loginName: "l", depCode: "", depName: "",
permissionLevel: "full", roles: ["BI-LEADER-ENERGY"],
};
function makeApp(user: AuthUser | undefined, deps: HydrogenHeatmapDependencies): Hono<{ Variables: { user: AuthUser } }> {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
if (user) c.set("user", user);
await next();
});
registerHydrogenHeatmapRoutes(app as never, deps);
return app;
}
test("buildWhere:片段顺序与参数顺序锁定(搜索与承担方)", () => {
const plain = buildWhere("2026-07-01", "2026-07-02", "", "all");
assert.equal(plain.params.length, 2);
assert.equal(plain.sql.includes("b.del_flag = '0'"), true);
assert.equal(plain.sql.includes("DATE(b.refuel_time) BETWEEN ? AND ?"), true);
assert.equal(plain.sql.includes("NOT LIKE '%西藏%'"), true, "有效坐标条件必须包含西藏排除");
const filtered = buildWhere("2026-07-01", "2026-07-02", "站A", "lingniu");
assert.deepEqual(filtered.params, ["2026-07-01", "2026-07-02", "%站A%"]);
assert.equal(filtered.sql.includes("COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0"), true);
const customer = buildWhere("2026-07-01", "2026-07-02", "", "customer");
assert.equal(customer.sql.includes("(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)"), true);
assert.deepEqual(customer.params, ["2026-07-01", "2026-07-02"]);
});
test("角色守卫:无能源角色返回 403 且不查库", async () => {
const { db, calls } = createMysql();
const app = makeApp({ ...ENERGY, roles: [] }, { mysql: db });
for (const url of ["/config", "/meta", "/points?startDate=2026-07-01&endDate=2026-07-02", "/nearby?lng=1&lat=1"]) {
assert.equal((await app.request(url)).status, 403, url);
}
assert.equal(calls.length, 0);
});
test("/config:缺少高德配置返回 503", async () => {
const prevKey = process.env.AMAP_WEB_KEY;
const prevCode = process.env.AMAP_SECURITY_JS_CODE;
delete process.env.AMAP_WEB_KEY;
delete process.env.AMAP_SECURITY_JS_CODE;
try {
const app = makeApp(ENERGY, { mysql: createMysql().db });
assert.equal((await app.request("/config")).status, 503);
} finally {
if (prevKey !== undefined) process.env.AMAP_WEB_KEY = prevKey;
if (prevCode !== undefined) process.env.AMAP_SECURITY_JS_CODE = prevCode;
}
});
test("/meta:数据水位、覆盖口径与站点选项映射不变", async () => {
const { db, calls } = createMysql((sql) => {
if (/AS start_date/.test(sql)) {
return [{
start_date: "2026-01-01", end_date: "2026-07-13",
total_refuel_count: "6", total_kg: "150.5", total_station_count: "2",
vehicle_count: "5", eligible_refuel_count: "5", eligible_kg: "140",
eligible_station_count: "2",
}];
}
if (/GROUP BY s\.id/.test(sql)) return [{ station_id: "11", station_name: "站A" }];
return [];
});
const app = makeApp(ENERGY, { mysql: db });
const res = await app.request("/meta");
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
startDate: "2026-01-01",
endDate: "2026-07-13",
totalRefuelCount: 6,
eligibleRefuelCount: 5,
excludedRefuelCount: 1,
gpsCoverageRate: 5 / 6,
totalKg: 150.5,
eligibleKg: 140,
vehicleCount: 5,
totalStationCount: 2,
eligibleStationCount: 2,
stations: [{ stationId: "11", stationName: "站A" }],
});
assert.equal(calls.length, 2);
});
test("/meta:无数据时回落到近 30 天滚动窗口", async () => {
const { db } = createMysql(() => [{}]);
const app = makeApp(ENERGY, { mysql: db });
const body = await (await app.request("/meta")).json() as Record<string, unknown>;
const today = new Date();
const expectedEnd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
assert.equal(body.endDate, expectedEnd, "不再回落到写死的历史区间");
assert.equal(body.totalRefuelCount, 0);
assert.equal(body.gpsCoverageRate, 0);
});
test("/points:日期倒置 400;正常路径两次查询与口径不变", async () => {
const { db, calls } = createMysql((sql) => {
if (/GROUP BY b\.station_id/.test(sql)) {
return [{
station_id: "11", station_name: "站A", address: "a", longitude: "113.1", latitude: "23.2",
kg: "100.5", refuel_count: "4", vehicle_count: "3",
first_refuel: "2026-07-01 08:00:00", last_refuel: "2026-07-02 09:00:00",
}];
}
return [{ kg: "100.5", refuel_count: "4", vehicle_count: "3", station_count: "1", day_count: "2" }];
});
const app = makeApp(ENERGY, { mysql: db });
assert.equal((await app.request("/points?startDate=2026-07-10&endDate=2026-07-01")).status, 400);
assert.equal(calls.length, 0);
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&metric=kg");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.kg, 100.5);
assert.equal(body.refuelCount, 4);
assert.equal(body.vehicleCount, 3);
assert.equal(body.stationCount, 1);
assert.equal(body.dayCount, 2);
assert.equal(body.payer, "all");
assert.equal(Array.isArray(body.topStations), true);
assert.equal(calls.length, 2);
assert.equal(calls[0].sql.includes("FROM hydrogen_fuel_ledger b LEFT JOIN hydrogen_station s"), true);
assert.equal(calls[1].sql.includes("COUNT(DISTINCT DATE(b.refuel_time)) AS day_count"), true);
assert.deepEqual(calls[1].params, ["2026-07-01", "2026-07-02"]);
});
test("/nearby:半径内无站点时不发第二条汇总查询", async () => {
const { db, calls } = createMysql(() => []);
const app = makeApp(ENERGY, { mysql: db });
const res = await app.request("/nearby?lng=113.1&lat=23.2&radiusKm=5&startDate=2026-07-01&endDate=2026-07-02");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.kg, 0);
assert.deepEqual(body.topStations, []);
assert.equal(calls.length, 1, "只应有一次站点查询");
});
test("/nearby:非法经纬度 400", async () => {
const app = makeApp(ENERGY, { mysql: createMysql().db });
assert.equal((await app.request("/nearby?lng=x&lat=23")).status, 400);
});
test("分层:路由文件不再直接书写 SQL(本域 SQL 为大小写混排)", () => {
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
const offenders = source.match(/\b(SELECT|FROM|WHERE|JOIN|GROUP BY|ORDER BY)\b/g) ?? [];
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts");
});
@@ -0,0 +1,163 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import mysqlPool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { recentDayRange } from '../../../shared/date-range.js';
import {
buildHydrogenPoints,
filterHydrogenStationsByRadius,
HEATMAP_DEFAULT_WINDOW_DAYS,
parseHydrogenHeatmapQuery,
parseNearbyQuery,
rankHydrogenStations,
serializeHydrogenStation,
} from './model.js';
import {
buildWhere,
loadMetaProfile,
loadNearbySummary,
loadPointsSummary,
loadStationOptions,
loadStations,
type MysqlDatabase,
} from './repository.js';
export interface HydrogenHeatmapDependencies {
mysql: MysqlDatabase;
}
export function registerHydrogenHeatmapRoutes(app: Hono, deps: HydrogenHeatmapDependencies): void {
const { mysql } = deps;
// 加氢热力图受能源角色控制(fail-closed)。
app.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
app.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503);
return c.json({ key, securityCode });
});
app.get('/meta', async (c) => {
const [profileRows, optionRows] = await Promise.all([
loadMetaProfile(mysql),
loadStationOptions(mysql),
]);
const profile = profileRows[0] || {};
const totalRefuelCount = Number(profile.total_refuel_count) || 0;
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS);
return c.json({
startDate: profile.start_date || fallback.start,
endDate: profile.end_date || fallback.end,
totalRefuelCount,
eligibleRefuelCount,
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0,
totalKg: Number(profile.total_kg) || 0,
eligibleKg: Number(profile.eligible_kg) || 0,
vehicleCount: Number(profile.vehicle_count) || 0,
totalStationCount: Number(profile.total_station_count) || 0,
eligibleStationCount: Number(profile.eligible_station_count) || 0,
stations: optionRows.map((row) => ({
stationId: String(row.station_id),
stationName: String(row.station_name),
})),
});
});
app.get('/points', async (c) => {
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
payer: c.req.query('payer'),
metric: c.req.query('metric'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const stations = await loadStations(mysql, startDate, endDate, query, payer);
const ranked = rankHydrogenStations(stations, metric);
const { points, max } = buildHydrogenPoints(ranked, metric);
const summaryRows = await loadPointsSummary(mysql, buildWhere(startDate, endDate, query, payer));
const summary = summaryRows[0] || {};
return c.json({
startDate,
endDate,
metric,
payer,
kg: Number(summary.kg) || 0,
refuelCount: Number(summary.refuel_count) || 0,
vehicleCount: Number(summary.vehicle_count) || 0,
stationCount: Number(summary.station_count) || 0,
dayCount: Number(summary.day_count) || 0,
points,
max,
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
app.get('/nearby', async (c) => {
const { center, radiusKm } = parseNearbyQuery({
lng: c.req.query('lng'),
lat: c.req.query('lat'),
radiusKm: c.req.query('radiusKm'),
});
if (!center) return c.json({ error: '经纬度参数无效' }, 400);
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
payer: c.req.query('payer'),
metric: c.req.query('metric'),
});
const stations = await loadStations(mysql, startDate, endDate, query, payer);
const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm);
const nearbyIds = nearby.map((row) => row.station_id);
let nearbySummary: { kg: number; refuelCount: number; vehicleCount: number; stationCount: number } = {
kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0,
};
if (nearbyIds.length) {
const placeholders = nearbyIds.map(() => '?').join(',');
const rows = await loadNearbySummary(
mysql,
buildWhere(startDate, endDate, query, payer),
placeholders,
nearbyIds,
);
nearbySummary = {
kg: Number(rows[0]?.kg) || 0,
refuelCount: Number(rows[0]?.refuel_count) || 0,
vehicleCount: Number(rows[0]?.vehicle_count) || 0,
stationCount: Number(rows[0]?.station_count) || 0,
};
}
const ranked = rankHydrogenStations(nearby, metric);
return c.json({
center,
radiusKm,
kg: nearbySummary.kg,
refuelCount: nearbySummary.refuelCount,
vehicleCount: nearbySummary.vehicleCount,
stationCount: nearbySummary.stationCount,
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
}
/** 生产用路由器:绑定真实连接池。 */
export function createHydrogenHeatmapRouter(): Hono {
const app = new Hono();
registerHydrogenHeatmapRoutes(app, { mysql: mysqlPool });
return app;
}
const app = createHydrogenHeatmapRouter();
export default app;
+3 -7
View File
@@ -1,7 +1,8 @@
import { loadMonitoringTargetRows } from './repository.js';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import pool from '../../db.js';
import pool from '../../db/mysql.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
import {
@@ -51,12 +52,7 @@ export interface RangeMileageResult {
}
async function fetchTargetRows(): Promise<TargetRow[]> {
return pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM lingniu_prod.tab_mileage_assessment_target t
JOIN lingniu_prod.tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as TargetRow[]);
return loadMonitoringTargetRows(pool).then(rows => rows as unknown as TargetRow[]);
}
export async function refreshMonitoringCache(): Promise<void> {
@@ -1,5 +1,6 @@
import { loadReportAssessmentRows } from './repository.js';
import type { RowDataPacket } from 'mysql2';
import pool from '../../db.js';
import pool from '../../db/mysql.js';
import type {
DailyMileageReport,
MileageReportBreakdown,
@@ -123,30 +124,7 @@ function shanghaiIsoNow(): string {
}
async function fetchAssessmentRows(): Promise<AssessmentRow[]> {
const [rows] = await pool.query<AssessmentRow[]>(`
SELECT
t.id AS target_id,
t.target_name,
t.annual_mileage_per_vehicle,
v.id AS assessment_vehicle_id,
v.plate_number,
DATE_FORMAT(v.assessment_start_date, '%Y-%m-%d') AS assessment_start_date,
DATE_FORMAT(v.assessment_end_date, '%Y-%m-%d') AS assessment_end_date,
v.total_mileage_task,
v.current_mileage,
v.completion_rate,
v.current_year_mileage_task,
v.current_year_mileage,
v.current_year_completion_rate,
v.current_year_is_qualified,
v.daily_required_mileage,
DATE_FORMAT(v.current_year_assessment_end_date, '%Y-%m-%d') AS current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_target t
JOIN lingniu_prod.tab_mileage_assessment_vehicle v
ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0
ORDER BY t.id, v.plate_number
`);
const rows = await loadReportAssessmentRows(pool) as unknown as AssessmentRow[];
return rows;
}
+16 -65
View File
@@ -1,5 +1,13 @@
import {
dailyReportSnapshotExists,
insertDailyReportSnapshot,
listDailyReportHistoryRows,
loadDailyReportPayload,
updateDailyReportPayload,
} from './repository.js';
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db.js';
import pool from '../../db/mysql.js';
import { ensureDailyReportTable, DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js';
import type {
DailyMileageReport,
MileageReportHistoryItem,
@@ -7,36 +15,6 @@ import type {
MileageReportSource,
} from '../../../shared/mileage/daily-report.js';
const TABLE = 'lingniu_prod.tab_mileage_daily_report';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS ${TABLE} (
id BIGINT NOT NULL AUTO_INCREMENT,
report_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ARCHIVED',
source VARCHAR(20) NOT NULL,
vehicle_count INT NOT NULL DEFAULT 0,
operating_count INT NOT NULL DEFAULT 0,
inventory_count INT NOT NULL DEFAULT 0,
daily_mileage DECIMAL(14,2) NOT NULL DEFAULT 0,
day_over_day_rate DECIMAL(8,2) NULL,
report_payload JSON NOT NULL,
generated_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_report_date (report_date),
KEY idx_generated_at (generated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='里程每日汇报归档快照'
`;
let ensured = false;
export async function ensureDailyReportTable(): Promise<void> {
if (ensured) return;
await pool.query(CREATE_TABLE_SQL);
ensured = true;
}
function parsePayload(value: unknown): DailyMileageReport | null {
if (!value) return null;
if (typeof value === 'object') return value as DailyMileageReport;
@@ -50,19 +28,13 @@ function parsePayload(value: unknown): DailyMileageReport | null {
export async function getDailyReportSnapshot(reportDate: string): Promise<DailyMileageReport | null> {
await ensureDailyReportTable();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`,
[reportDate],
);
const rows = await loadDailyReportPayload(pool, { reportDate });
return parsePayload(rows[0]?.report_payload);
}
export async function hasDailyReportSnapshot(reportDate: string): Promise<boolean> {
await ensureDailyReportTable();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT 1 AS found FROM ${TABLE} WHERE report_date = ? LIMIT 1`,
[reportDate],
);
const rows = await dailyReportSnapshotExists(pool, { reportDate });
return rows.length > 0;
}
@@ -86,41 +58,20 @@ export async function saveDailyReportSnapshot(
archived.generatedAt.replace('T', ' ').replace(/Z$/, '').slice(0, 19),
];
const insert = options.replace ? 'REPLACE' : 'INSERT IGNORE';
const [result] = await pool.query<ResultSetHeader>(
`${insert} INTO ${TABLE}
(report_date, status, source, vehicle_count, operating_count, inventory_count,
daily_mileage, day_over_day_rate, report_payload, generated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)`,
values,
);
return result.affectedRows > 0;
const affected = await insertDailyReportSnapshot(pool, { replace: Boolean(options.replace), values });
return affected > 0;
}
export async function updateDailyReportSnapshotPayload(report: DailyMileageReport): Promise<boolean> {
await ensureDailyReportTable();
const [result] = await pool.query<ResultSetHeader>(
`UPDATE ${TABLE}
SET report_payload = CAST(? AS JSON)
WHERE report_date = ?`,
[JSON.stringify(report), report.reportDate],
);
return result.affectedRows > 0;
const affected = await updateDailyReportPayload(pool, { report: JSON.stringify(report), reportDate: report.reportDate });
return affected > 0;
}
export async function listDailyReportHistory(limit = 90): Promise<MileageReportHistoryResponse> {
await ensureDailyReportTable();
const safeLimit = Math.min(366, Math.max(1, limit));
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date,
status, source, vehicle_count, operating_count, inventory_count,
daily_mileage, day_over_day_rate,
DATE_FORMAT(generated_at, '%Y-%m-%dT%H:%i:%s+08:00') AS generated_at
FROM ${TABLE}
ORDER BY report_date DESC
LIMIT ?`,
[safeLimit],
);
const rows = await listDailyReportHistoryRows(pool, { safeLimit });
const items: MileageReportHistoryItem[] = rows.map(row => ({
reportDate: row.report_date,
status: row.status,
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import pool from '../../db/mysql.js';
import monitoring from './monitoring.js';
import { clearOneOsMileageCache } from './oneos-api.js';
test('single-day monitoring merges all target rows and handles an empty target table', async (t) => {
const previousUrl = process.env.ONEOS_MILEAGE_API_BASE_URL;
const previousKey = process.env.ONEOS_MILEAGE_API_KEY;
process.env.ONEOS_MILEAGE_API_BASE_URL = 'https://oneos.test';
process.env.ONEOS_MILEAGE_API_KEY = 'test-key';
t.after(() => {
if (previousUrl === undefined) delete process.env.ONEOS_MILEAGE_API_BASE_URL;
else process.env.ONEOS_MILEAGE_API_BASE_URL = previousUrl;
if (previousKey === undefined) delete process.env.ONEOS_MILEAGE_API_KEY;
else process.env.ONEOS_MILEAGE_API_KEY = previousKey;
clearOneOsMileageCache();
});
let targets = [
{ id: 1, target_name: '目标A', plate_number: '粤A10001' },
{ id: 2, target_name: '目标B', plate_number: '粤A10001' },
];
t.mock.method(pool, 'query', async (sql: string) => {
if (sql.includes('tab_mileage_assessment_target')) return [targets, []];
assert.ok(sql.includes('FROM vehicle_info vi'));
return [[{ plate: '粤A10001', vin: 'test-vin' }], []];
});
t.mock.method(globalThis, 'fetch', async (_url: unknown, init: RequestInit) => {
const { date } = JSON.parse(String(init.body));
return Response.json({ code: 'SUCCESS', data: [{
plateNumber: '粤A10001', date, status: 'NORMAL',
dailyMileageKm: 12, totalMileageKm: 100, sourceProtocol: 'GB32960',
}] });
});
for (const expectedTargets of [['目标A', '目标B'], []]) {
clearOneOsMileageCache();
const response = await monitoring.request('/?startDate=2026-09-16&endDate=2026-09-16&sourcePriority=instrument');
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.total, 1);
assert.equal(payload.vehicles[0].dailyKm, 12);
assert.deepEqual(payload.vehicles[0].targetNames, expectedTargets);
targets = [];
}
});
+322
View File
@@ -0,0 +1,322 @@
import type { RowDataPacket } from 'mysql2';
import { DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js';
/**
* 里程域的数据访问。
* SQL 集中在此;缓存/口径计算仍在 cache.ts、daily-report-* 等模块里。
*/
/** 只需要 query 能力的连接。 */
export interface Database {
query<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
// ---------------------------------------------------------------------------
// 里程域
// ---------------------------------------------------------------------------
/** 车辆关联信息(客户 / 部门 / 经理 / 租赁状态 / 主体 / 项目 / 品牌)。 */
export const VEHICLE_INFO_SQL = `SELECT
vi.plate_number AS plate,
vi.vin AS vin,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
COALESCE(c.business_department_name, vor.business_dept) AS department,
COALESCE(c.business_manager_name, vor.business_manager) AS manager,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
CASE vs.operation_status
WHEN '1' THEN '租赁'
WHEN '2' THEN '自营'
WHEN '3' THEN '可运营'
WHEN '4' THEN '待运营'
WHEN '5' THEN '退出运营'
ELSE vs.operation_status
END AS rent_status,
NULLIF(vi.registered_ownership, '') AS entity,
COALESCE(c.project_name, vor.project_name) AS project,
COALESCE(vi_operation_city.NAME, NULLIF(vi.operation_city, '')) AS inventory_location,
CASE vm.brand
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
WHEN 'yuejin' THEN '跃进'
WHEN 'feichi' THEN '飞驰'
WHEN 'sulong' THEN '苏龙'
WHEN 'higer' THEN '海格'
WHEN 'dongfeng' THEN '东风'
WHEN 'yutong' THEN '宇通'
WHEN 'chufeng' THEN '楚风'
WHEN 'tonghua' THEN '通华'
WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS brand_label
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
LEFT JOIN common_district vi_operation_city
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
AND vi_operation_city.STATUS = 'VALID'
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
/** 按车牌批量查询车辆关联信息。 */
export async function loadVehicleInfoByPlates(db: Database, args: { plates: string[] }): Promise<RowDataPacket[]> {
const { plates } = args;
const [rows] = await db.query<RowDataPacket[]>(`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`, plates);
return rows;
}
/** 考核目标下的车牌。 */
export async function loadTargetVehiclePlates(db: Database, args: { targetId: string }): Promise<RowDataPacket[]> {
const { targetId } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0`, [targetId]);
return rows;
}
/** 监控缓存的考核目标 × 车牌(全量)。 */
export async function loadMonitoringTargetRows(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT t.id, t.target_name, v.plate_number
FROM lingniu_prod.tab_mileage_assessment_target t
JOIN lingniu_prod.tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`, []);
return rows;
}
/** 考核目标列表。 */
export async function loadAssessmentTargetRows(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id`, []);
return rows;
}
/** 各目标的车辆统计。 */
export async function loadAssessmentVehicleStats(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT
target_id, COUNT(*) as total,
SUM(today_mileage) as today_total,
SUM(current_mileage) as cumulative_total,
AVG(current_year_completion_rate) as avg_completion,
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id`, []);
return rows;
}
/** 首年目标统计。 */
export async function loadAssessmentFirstYearStats(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT
v.target_id,
COUNT(*) as first_year_total,
SUM(t.annual_mileage_per_vehicle) as first_year_target,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
WHERE v.is_deleted = 0
GROUP BY v.target_id`, []);
return rows;
}
/** 年度考核统计。 */
export async function loadAssessmentYearlyStats(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT
v.target_id,
y.year_number,
COUNT(*) as vehicle_count,
SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
/ NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
JOIN (
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
WHERE v.is_deleted = 0
GROUP BY v.target_id, y.year_number
ORDER BY v.target_id, y.year_number`, []);
return rows;
}
/** 年度分期统计。 */
export async function loadAssessmentYearlyPeriodStats(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT
v.target_id,
y.year_number,
DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
JOIN (
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
WHERE v.is_deleted = 0
GROUP BY v.target_id, y.year_number, v.assessment_start_date
ORDER BY v.target_id, y.year_number, v.assessment_start_date`, []);
return rows;
}
/** 分期统计。 */
export async function loadAssessmentPeriodStats(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date`, []);
return rows;
}
/** 考核车辆明细(全量)。 */
export async function loadTargetVehicleRows(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0`, []);
return rows;
}
/** 单目标年度考核车辆。 */
export async function loadYearlyAssessments(db: Database, args: { targetId: string }): Promise<RowDataPacket[]> {
const { targetId } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM lingniu_prod.tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`, [targetId]);
return rows;
}
/** 读取日报归档负载。 */
export async function loadDailyReportPayload(db: Database, args: { reportDate: string }): Promise<RowDataPacket[]> {
const { reportDate } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`, [reportDate]);
return rows;
}
/** 日报是否已归档。 */
export async function dailyReportSnapshotExists(db: Database, args: { reportDate: string }): Promise<RowDataPacket[]> {
const { reportDate } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT 1 AS found FROM ${TABLE} WHERE report_date = ? LIMIT 1`, [reportDate]);
return rows;
}
/** 写入日报归档(REPLACE 或 INSERT IGNORE)。 */
export async function insertDailyReportSnapshot(db: Database, args: { replace: boolean; values: unknown[] }): Promise<number> {
const { replace, values } = args;
const [rows] = await db.query<any>(`${replace ? "REPLACE" : "INSERT IGNORE"} INTO ${TABLE}
(report_date, status, source, vehicle_count, operating_count, inventory_count,
daily_mileage, day_over_day_rate, report_payload, generated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)`, values);
return rows;
}
/** 更新日报负载。 */
export async function updateDailyReportPayload(db: Database, args: { report: unknown; reportDate: string }): Promise<number> {
const { report, reportDate } = args;
const [rows] = await db.query<any>(`UPDATE ${TABLE}
SET report_payload = CAST(? AS JSON)
WHERE report_date = ?`, [JSON.stringify(report), reportDate]);
return rows;
}
/** 日报归档历史。 */
export async function listDailyReportHistoryRows(db: Database, args: { safeLimit: number }): Promise<RowDataPacket[]> {
const { safeLimit } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date,
status, source, vehicle_count, operating_count, inventory_count,
daily_mileage, day_over_day_rate,
DATE_FORMAT(generated_at, '%Y-%m-%dT%H:%i:%s+08:00') AS generated_at
FROM ${TABLE}
ORDER BY report_date DESC
LIMIT ?`, [safeLimit]);
return rows;
}
/** 日报所需的考核车辆行。 */
export async function loadReportAssessmentRows(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT
t.id AS target_id,
t.target_name,
t.annual_mileage_per_vehicle,
v.id AS assessment_vehicle_id,
v.plate_number,
DATE_FORMAT(v.assessment_start_date, '%Y-%m-%d') AS assessment_start_date,
DATE_FORMAT(v.assessment_end_date, '%Y-%m-%d') AS assessment_end_date,
v.total_mileage_task,
v.current_mileage,
v.completion_rate,
v.current_year_mileage_task,
v.current_year_mileage,
v.current_year_completion_rate,
v.current_year_is_qualified,
v.daily_required_mileage,
DATE_FORMAT(v.current_year_assessment_end_date, '%Y-%m-%d') AS current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_target t
JOIN lingniu_prod.tab_mileage_assessment_vehicle v
ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0
ORDER BY t.id, v.plate_number`, []);
return rows;
}
/** 全量车辆关联信息。 */
export async function loadVehicleInfoMapRows(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(VEHICLE_INFO_SQL);
return rows;
}
+19 -96
View File
@@ -1,5 +1,15 @@
import {
loadAssessmentFirstYearStats,
loadAssessmentPeriodStats,
loadAssessmentTargetRows,
loadAssessmentVehicleStats,
loadAssessmentYearlyPeriodStats,
loadAssessmentYearlyStats,
loadTargetVehicleRows,
loadYearlyAssessments,
} from './repository.js';
import { Hono } from 'hono';
import pool from '../../db.js';
import pool from '../../db/mysql.js';
import { getCache } from './cache.js';
import { fetchOneOsDailyMileage } from './oneos-api.js';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
@@ -9,73 +19,19 @@ const app = new Hono();
app.get('/', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const targets = await loadAssessmentTargetRows(pool);
const [vehicleStats] = await pool.execute(`
SELECT
target_id, COUNT(*) as total,
SUM(today_mileage) as today_total,
SUM(current_mileage) as cumulative_total,
AVG(current_year_completion_rate) as avg_completion,
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id
`) as [any[], unknown];
const vehicleStats = await loadAssessmentVehicleStats(pool);
const statsMap = new Map<number, any>();
for (const s of vehicleStats) statsMap.set(s.target_id, s);
const [firstYearRows] = await pool.execute(`
SELECT
v.target_id,
COUNT(*) as first_year_total,
SUM(t.annual_mileage_per_vehicle) as first_year_target,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
WHERE v.is_deleted = 0
GROUP BY v.target_id
`) as [any[], unknown];
const firstYearRows = await loadAssessmentFirstYearStats(pool);
const firstYearMap = new Map<number, any>();
for (const s of firstYearRows) firstYearMap.set(s.target_id, s);
const [yearlyRows] = await pool.execute(`
SELECT
v.target_id,
y.year_number,
COUNT(*) as vehicle_count,
SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
/ NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
JOIN (
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
WHERE v.is_deleted = 0
GROUP BY v.target_id, y.year_number
ORDER BY v.target_id, y.year_number
`) as [any[], unknown];
const yearlyRows = await loadAssessmentYearlyStats(pool);
const yearlyMap = new Map<number, any[]>();
for (const row of yearlyRows) {
@@ -84,22 +40,7 @@ app.get('/', async (c) => {
yearlyMap.set(row.target_id, list);
}
const [yearlyPeriodRows] = await pool.execute(`
SELECT
v.target_id,
y.year_number,
DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
JOIN (
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
WHERE v.is_deleted = 0
GROUP BY v.target_id, y.year_number, v.assessment_start_date
ORDER BY v.target_id, y.year_number, v.assessment_start_date
`) as [any[], unknown];
const yearlyPeriodRows = await loadAssessmentYearlyPeriodStats(pool);
const yearlyPeriodsMap = new Map<string, string[]>();
for (const row of yearlyPeriodRows) {
@@ -109,15 +50,7 @@ app.get('/', async (c) => {
yearlyPeriodsMap.set(key, list);
}
const [periodRows] = await pool.execute(`
SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date
`) as [any[], unknown];
const periodRows = await loadAssessmentPeriodStats(pool);
const periodsMap = new Map<number, string[]>();
for (const p of periodRows) {
@@ -134,9 +67,7 @@ app.get('/', async (c) => {
}
}
const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown];
const targetVehicleRows = await loadTargetVehicleRows(pool);
const targetIdPlatesMap = new Map<number, string[]>();
for (const r of targetVehicleRows) {
@@ -241,15 +172,7 @@ app.get('/:id/vehicles', async (c) => {
const date = c.req.query('date') || '';
try {
const [rows] = await pool.execute(
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM lingniu_prod.tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]
) as [any[], unknown];
const rows = await loadYearlyAssessments(pool, { targetId });
const plates: string[] = rows.map((r: any) => r.plate_number);
const infoMap = await fetchVehicleInfoByPlates(plates);
+3 -5
View File
@@ -1,5 +1,6 @@
import { loadTargetVehiclePlates } from './repository.js';
import { Hono } from 'hono';
import pool from '../../db.js';
import pool from '../../db/mysql.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
const app = new Hono();
@@ -11,10 +12,7 @@ app.get('/', async (c) => {
try {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as [{ plate_number: string }[], unknown];
const vehicleRows = await loadTargetVehiclePlates(pool, { targetId });
plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]);
}
+4 -73
View File
@@ -1,76 +1,10 @@
import pool from '../../db.js';
import { loadVehicleInfoByPlates, loadVehicleInfoMapRows } from './repository.js';
import pool from '../../db/mysql.js';
import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目、品牌) */
export const VEHICLE_INFO_SQL = `SELECT
vi.plate_number AS plate,
vi.vin AS vin,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer,
COALESCE(c.business_department_name, vor.business_dept) AS department,
COALESCE(c.business_manager_name, vor.business_manager) AS manager,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id,
CASE vs.operation_status
WHEN '1' THEN '租赁'
WHEN '2' THEN '自营'
WHEN '3' THEN '可运营'
WHEN '4' THEN '待运营'
WHEN '5' THEN '退出运营'
ELSE vs.operation_status
END AS rent_status,
NULLIF(vi.registered_ownership, '') AS entity,
COALESCE(c.project_name, vor.project_name) AS project,
COALESCE(vi_operation_city.NAME, NULLIF(vi.operation_city, '')) AS inventory_location,
CASE vm.brand
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
WHEN 'yuejin' THEN '跃进'
WHEN 'feichi' THEN '飞驰'
WHEN 'sulong' THEN '苏龙'
WHEN 'higer' THEN '海格'
WHEN 'dongfeng' THEN '东风'
WHEN 'yutong' THEN '宇通'
WHEN 'chufeng' THEN '楚风'
WHEN 'tonghua' THEN '通华'
WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS brand_label
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
LEFT JOIN common_district vi_operation_city
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
AND vi_operation_city.STATUS = 'VALID'
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown];
const rows = await loadVehicleInfoMapRows(pool) as unknown as VehicleInfoRow[];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
@@ -81,10 +15,7 @@ export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>
/** 查询指定车牌的关联信息 */
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map();
const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const rows = await loadVehicleInfoByPlates(pool, { plates }) as unknown as VehicleInfoRow[];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
+4 -4
View File
@@ -6,12 +6,12 @@ import { canAccessScheduling } from '../../auth/types.js';
const app = new Hono();
// Module-level access guard. When auth middleware is active, `user` is set and
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
// When auth is bypassed (dev), `user` is undefined and requests pass through.
// Module-level access guard (fail-closed): requires BI-SCHEDULE-OPT.
// Auth middleware runs before this router, so a missing `user` is an anomaly and
// must be treated as "no permission" rather than silently allowed.
app.use('*', async (c, next) => {
const user = (c as any).get('user') as AuthUser | undefined;
if (user && !canAccessScheduling(user.roles)) {
if (!canAccessScheduling(user?.roles)) {
return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
}
return next();
+166 -203
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mysqlPool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js';
import type {
NotifyRequest,
@@ -18,233 +18,196 @@ import {
type ActiveNotificationRow,
type NotificationDbRow,
} from './notification-model.js';
import {
countRecentInterventions,
findBlockingNotification,
findNotificationById,
insertNotification,
listActiveNotifications,
listNotifications,
updateNotification,
type Database,
} from './repository.js';
const app = new Hono();
export interface NotifyDependencies {
db: Database;
}
/**
* Count non-cancelled interventions created within the last 7 days.
*/
export async function fetchRecentInterventionCount(): Promise<number> {
const [rows] = (await pool.execute(
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
WHERE status != 'cancelled'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
)) as [any[], unknown];
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
/** 近 7 天未取消的干预条数(供建议列表使用)。 */
export async function fetchRecentInterventionCount(db: Database): Promise<number> {
return countRecentInterventions(db);
}
/**
* Fetch notification status map for the currently-visible (suggestion, candidate) pairs.
* Key: `${suggestionId}::${candidatePlate}` → latest non-cancelled notification.
* 当前可见 (suggestion, candidate) 组合的通知状态映射。
* Key: `${suggestionId}::${candidatePlate}` → 最近一条未取消通知。
*/
export async function fetchActiveNotificationMap(): Promise<
Map<string, { id: number; status: NotificationStatus }>
> {
const [rows] = (await pool.execute(
`SELECT id, suggestion_id, candidate_plate, status, created_at
FROM tab_scheduling_notifications
WHERE status != 'cancelled'
ORDER BY created_at DESC`,
)) as [any[], unknown];
return buildActiveNotificationMap(rows as ActiveNotificationRow[]);
export async function fetchActiveNotificationMap(
db: Database,
): Promise<Map<string, { id: number; status: NotificationStatus }>> {
const rows = await listActiveNotifications(db);
return buildActiveNotificationMap(rows as unknown as ActiveNotificationRow[]);
}
async function insertNotification(
req: NotifyRequest,
operator: { id: string | null; name: string | null },
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
// Business rule: each current vehicle (suggestion) can have AT MOST ONE
// active intervention at a time. Any non-cancelled record for the same
// suggestion_id blocks further interventions until it is cancelled.
const [existing] = (await pool.execute(
`SELECT id, candidate_plate FROM tab_scheduling_notifications
WHERE suggestion_id = ? AND status != 'cancelled'
LIMIT 1`,
[req.suggestionId],
)) as [any[], unknown];
export function registerNotifyRoutes(app: Hono, deps: NotifyDependencies): void {
const { db } = deps;
if (existing.length > 0) {
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
}
const [result] = (await pool.execute(
`INSERT INTO tab_scheduling_notifications
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status)
VALUES (?, ?, ?, ?, ?, 'sent')`,
[req.suggestionId, req.currentPlate, req.candidatePlate, operator.id, operator.name],
)) as [any, unknown];
const insertedId = Number(result.insertId);
const [rows] = (await pool.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[insertedId],
)) as [any[], unknown];
return rowToNotificationRecord(rows[0] as NotificationDbRow);
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// POST /api/scheduling/notify — single notify
app.post('/', async (c) => {
try {
const body = await c.req.json<NotifyRequest>();
const { suggestionId, currentPlate, candidatePlate } = body;
if (!hasRequiredNotifyFields(body)) {
return c.json({ success: false, message: '缺少必要参数' }, 400);
/**
* 登记一条干预。
* 业务规则:同一 suggestion 同一时刻最多一条生效干预,已有未取消记录则跳过。
*/
async function insertOne(
req: NotifyRequest,
operator: { id: string | null; name: string | null },
): Promise<NotificationRecord | { skipped: true; existingPlate: string }> {
const existing = await findBlockingNotification(db, req.suggestionId);
if (existing.length > 0) {
return { skipped: true, existingPlate: existing[0].candidate_plate as string };
}
const user = (c as any).get('user') as AuthUser | undefined;
const operator = {
id: user?.userId ?? null,
name: user?.userName ?? null,
};
const insertedId = await insertNotification(db, {
suggestionId: req.suggestionId,
currentPlate: req.currentPlate,
candidatePlate: req.candidatePlate,
operatorId: operator.id,
operatorName: operator.name,
});
const row = await findNotificationById(db, insertedId);
return rowToNotificationRecord(row as NotificationDbRow);
}
const result = await insertNotification(body, operator);
if ('skipped' in result) {
return c.json(
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
409,
// POST /api/scheduling/notify — single notify
app.post('/', async (c) => {
try {
const body = await c.req.json<NotifyRequest>();
const { suggestionId, currentPlate, candidatePlate } = body;
if (!hasRequiredNotifyFields(body)) {
return c.json({ success: false, message: '缺少必要参数' }, 400);
}
const user = (c as any).get('user') as AuthUser | undefined;
const operator = { id: user?.userId ?? null, name: user?.userName ?? null };
const result = await insertOne(body, operator);
if ('skipped' in result) {
return c.json(
{ success: false, message: `此车已有干预(候选车 ${result.existingPlate}),请先解除` },
409,
);
}
console.log(
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
);
return c.json({
success: true,
message: `干预已登记:${currentPlate} → ${candidatePlate}`,
record: result,
});
} catch (e: unknown) {
console.error('scheduling notify error:', e);
return c.json({ success: false, message: '登记干预失败' }, 500);
}
});
console.log(
`[scheduling:notify] operator=${operator.name} suggestion=${suggestionId} current=${currentPlate} candidate=${candidatePlate}`,
);
return c.json({
success: true,
message: `干预已登记:${currentPlate} → ${candidatePlate}`,
record: result,
});
} catch (e: unknown) {
console.error('scheduling notify error:', e);
return c.json({ success: false, message: '登记干预失败' }, 500);
}
});
// POST /api/scheduling/notify/batch — bulk notify
app.post('/batch', async (c) => {
try {
const body = await c.req.json<NotifyBatchRequest>();
if (!Array.isArray(body.items) || body.items.length === 0) {
return c.json({ success: false, message: '缺少 items' }, 400);
}
const user = (c as any).get('user') as AuthUser | undefined;
const operator = {
id: user?.userId ?? null,
name: user?.userName ?? null,
};
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
for (const item of body.items) {
if (!hasRequiredNotifyFields(item)) {
result.failed++;
continue;
// POST /api/scheduling/notify/batch — bulk notify
app.post('/batch', async (c) => {
try {
const body = await c.req.json<NotifyBatchRequest>();
if (!Array.isArray(body.items) || body.items.length === 0) {
return c.json({ success: false, message: '缺少 items' }, 400);
}
try {
const r = await insertNotification(item, operator);
if ('skipped' in r) result.skipped++;
else {
result.success++;
result.records.push(r);
const user = (c as any).get('user') as AuthUser | undefined;
const operator = { id: user?.userId ?? null, name: user?.userName ?? null };
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
for (const item of body.items) {
if (!hasRequiredNotifyFields(item)) {
result.failed++;
continue;
}
try {
const r = await insertOne(item, operator);
if ('skipped' in r) result.skipped++;
else {
result.success++;
result.records.push(r);
}
} catch {
result.failed++;
}
} catch {
result.failed++;
}
console.log(
`[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`,
);
return c.json({
success: true,
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
result,
});
} catch (e: unknown) {
console.error('scheduling batch notify error:', e);
return c.json({ success: false, message: '批量干预失败' }, 500);
}
});
console.log(
`[scheduling:notify:batch] operator=${operator.name} total=${body.items.length} success=${result.success} skipped=${result.skipped} failed=${result.failed}`,
);
return c.json({
success: true,
message: `批量干预:成功 ${result.success},跳过 ${result.skipped},失败 ${result.failed}`,
result,
});
} catch (e: unknown) {
console.error('scheduling batch notify error:', e);
return c.json({ success: false, message: '批量干预失败' }, 500);
}
});
// GET /api/scheduling/notify — list all notifications (history)
app.get('/', async (c) => {
try {
const status = c.req.query('status');
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
const where: string[] = [];
const params: (string | number)[] = [];
if (status) {
where.push('status = ?');
params.push(status);
// GET /api/scheduling/notify — list all notifications (history)
app.get('/', async (c) => {
try {
const rawStatus = c.req.query('status');
const limit = Math.min(Number(c.req.query('limit')) || 200, 500);
const rows = await listNotifications(db, { status: rawStatus || null, limit });
return c.json({ records: rows.map(rowToNotificationRecord) });
} catch (e: unknown) {
console.error('scheduling notifications list error:', e);
return c.json({ records: [] }, 500);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
params.push(limit);
});
const [rows] = (await pool.query(
`SELECT * FROM tab_scheduling_notifications
${whereSql}
ORDER BY created_at DESC
LIMIT ?`,
params,
)) as [any[], unknown];
// PATCH /api/scheduling/notify/:id — update status (execute / cancel)
app.patch('/:id', async (c) => {
try {
const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) {
return c.json({ success: false, message: 'id 无效' }, 400);
}
return c.json({ records: (rows as NotificationDbRow[]).map(rowToNotificationRecord) });
} catch (e: unknown) {
console.error('scheduling notifications list error:', e);
return c.json({ records: [] }, 500);
}
});
const body = await c.req.json<UpdateNotificationRequest>();
if (!body.status) {
return c.json({ success: false, message: '缺少 status' }, 400);
}
// PATCH /api/scheduling/notify/:id — update status (execute / cancel)
app.patch('/:id', async (c) => {
try {
const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) {
return c.json({ success: false, message: 'id 无效' }, 400);
if (!isNotificationStatus(body.status)) {
return c.json({ success: false, message: 'status 不合法' }, 400);
}
const { fields, params } = buildNotificationUpdate(body, id);
await updateNotification(db, fields, params);
const row = await findNotificationById(db, id);
if (!row) {
return c.json({ success: false, message: '记录不存在' }, 404);
}
return c.json({ success: true, record: rowToNotificationRecord(row) });
} catch (e: unknown) {
console.error('scheduling notification update error:', e);
return c.json({ success: false, message: '更新失败' }, 500);
}
});
}
const body = await c.req.json<UpdateNotificationRequest>();
if (!body.status) {
return c.json({ success: false, message: '缺少 status' }, 400);
}
if (!isNotificationStatus(body.status)) {
return c.json({ success: false, message: 'status 不合法' }, 400);
}
const { fields, params } = buildNotificationUpdate(body, id);
await pool.execute(
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
params,
);
const [rows] = (await pool.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[id],
)) as [any[], unknown];
if (rows.length === 0) {
return c.json({ success: false, message: '记录不存在' }, 404);
}
return c.json({
success: true,
record: rowToNotificationRecord(rows[0] as NotificationDbRow),
});
} catch (e: unknown) {
console.error('scheduling notification update error:', e);
return c.json({ success: false, message: '更新失败' }, 500);
}
});
/** 生产用路由器:绑定真实连接池。 */
export function createNotifyRouter(): Hono {
const app = new Hono();
registerNotifyRoutes(app, { db: mysqlPool });
return app;
}
const app = createNotifyRouter();
export default app;
+176
View File
@@ -0,0 +1,176 @@
import type { RowDataPacket } from 'mysql2';
import type { NotificationStatus } from './types.js';
import type { NotificationDbRow } from './notification-model.js';
/**
* 智能调度的数据访问。
*
* 通知/干预记录的全部 SQL 集中在此;字段映射与更新字段拼装仍在
* notification-model.ts(纯函数,已有单测)。SQL 文本与参数顺序由
* notify-routes.test.ts 的契约测试锁定。
*/
/** MySQL 连接:只声明用到的能力。 */
export interface Database {
query<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
/** 近 7 天未取消的干预条数。 */
export async function countRecentInterventions(db: Database): Promise<number> {
const [rows] = (await db.execute(
`SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications
WHERE status != 'cancelled'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)`,
)) as [any[], unknown];
return rows.length > 0 ? Number(rows[0].cnt) || 0 : 0;
}
/** 全部未取消的通知(按创建时间倒序)。 */
export async function listActiveNotifications(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(
`SELECT id, suggestion_id, candidate_plate, status, created_at
FROM tab_scheduling_notifications
WHERE status != 'cancelled'
ORDER BY created_at DESC`,
)) as [RowDataPacket[], unknown];
return rows;
}
/**
* 同一 suggestion 是否已有生效干预(每个当前车辆同一时刻只允许一条)。
*/
export async function findBlockingNotification(
db: Database,
suggestionId: string,
): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(
`SELECT id, candidate_plate FROM tab_scheduling_notifications
WHERE suggestion_id = ? AND status != 'cancelled'
LIMIT 1`,
[suggestionId],
)) as [RowDataPacket[], unknown];
return rows;
}
export interface NewNotification {
suggestionId: string;
currentPlate: string;
candidatePlate: string;
operatorId: string | null;
operatorName: string | null;
}
export async function insertNotification(db: Database, row: NewNotification): Promise<number> {
const [result] = (await db.execute(
`INSERT INTO tab_scheduling_notifications
(suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status)
VALUES (?, ?, ?, ?, ?, 'sent')`,
[row.suggestionId, row.currentPlate, row.candidatePlate, row.operatorId, row.operatorName],
)) as [any, unknown];
return Number(result.insertId);
}
export async function findNotificationById(db: Database, id: number): Promise<NotificationDbRow | undefined> {
const [rows] = (await db.execute(
`SELECT * FROM tab_scheduling_notifications WHERE id = ?`,
[id],
)) as [NotificationDbRow[], unknown];
return rows[0];
}
/** 历史列表:可选状态过滤 + 条数上限。 */
export async function listNotifications(
db: Database,
options: { status: string | null; limit: number },
): Promise<NotificationDbRow[]> {
const where: string[] = [];
const params: (string | number)[] = [];
if (options.status) {
where.push('status = ?');
params.push(options.status);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
params.push(options.limit);
const [rows] = (await db.query(
`SELECT * FROM tab_scheduling_notifications
${whereSql}
ORDER BY created_at DESC
LIMIT ?`,
params,
)) as [NotificationDbRow[], unknown];
return rows;
}
/** 按 buildNotificationUpdate 产出的片段更新记录。 */
export async function updateNotification(
db: Database,
fields: string[],
params: (string | number | null)[],
): Promise<void> {
await db.execute(
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
params,
);
}
/** 供 suggestions 视图使用的活跃状态映射所需的状态类型。 */
export type { NotificationStatus };
/** 考核目标(用于年度目标里程)。 */
export async function loadAssessmentTargets(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
)) as [RowDataPacket[], unknown];
return rows;
}
/** 考核车辆明细。 */
export async function loadAssessmentVehicles(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(`
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0`)) as [RowDataPacket[], unknown];
return rows;
}
/** 在运营车辆的类型(vehicle_info × vehicle_model)。 */
export async function loadVehicleTypes(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`)) as [RowDataPacket[], unknown];
return rows;
}
/** 实时车辆省份/城市。 */
export async function loadRealtimeLocations(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(`
SELECT plate_number, province, city
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL`)) as [RowDataPacket[], unknown];
return rows;
}
/** 库存车辆(operation_status 3/4 且非停用)。 */
export async function loadInventoryVehicles(db: Database): Promise<RowDataPacket[]> {
const [rows] = (await db.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') IN ('3','4')
AND COALESCE(vs.vehicle_status, '') <> '4'`)) as [RowDataPacket[], unknown];
return rows;
}
+239
View File
@@ -0,0 +1,239 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
import { Hono } from "hono";
import type { AuthUser } from "../../auth/types.js";
import { registerNotifyRoutes, fetchActiveNotificationMap, fetchRecentInterventionCount } from "./notify.js";
import { registerSuggestionsRoutes } from "./suggestions.js";
import type { Database } from "./repository.js";
interface Call {
via: "query" | "execute";
sql: string;
params: unknown[];
}
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
/** 依次弹出预设结果;未预设则返回空数组。 */
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
const calls: Call[] = [];
const queue = [...resultSets];
const mk = (via: "query" | "execute") => async (sql: string, params?: any[]) => {
calls.push({ via, sql: normalize(sql), params: (params ?? []) as unknown[] });
return [queue.shift() ?? [], []] as never;
};
return { db: { query: mk("query"), execute: mk("execute") }, calls };
}
const USER: AuthUser = {
userId: "u-1", userName: "张三", loginName: "zhangsan", depCode: "", depName: "",
permissionLevel: "full", roles: ["BI-SCHEDULE-OPT"],
};
function appWith(register: (app: any, deps: any) => void, db: Database, extra: Record<string, unknown> = {}) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
c.set("user", USER);
await next();
});
register(app, { db, ...extra });
return app;
}
/** 建议列表依赖的跨域数据源:测试中替成空实现,避免触达真实库/外部 API。 */
const STUB_DEPS = {
loadVehicleInfoMap: async () => new Map(),
loadOneOsMileageDates: async () => new Map(),
};
const NOTIFICATION_ROW = {
id: 5,
suggestion_id: "sug-1",
current_plate: "粤A1",
candidate_plate: "粤B2",
operator_id: "u-1",
operator_name: "张三",
status: "sent",
created_at: "2026-07-01 10:00:00",
updated_at: "2026-07-01 10:00:00",
executed_at: null,
notes: null,
before_mileage: null,
after_mileage: null,
};
test("干预登记:先查是否已有生效记录,再 INSERT 并回读", async () => {
const { db, calls } = createMockDb([[], { insertId: 5 }, [NOTIFICATION_ROW]]);
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestionId: "sug-1", currentPlate: "粤A1", candidatePlate: "粤B2" }),
});
assert.equal(res.status, 200);
const payload = await res.json() as { success: boolean; record: Record<string, unknown> };
assert.equal(payload.success, true);
assert.equal(payload.record.candidatePlate, "粤B2");
assert.equal(calls.length, 3);
assert.equal(
calls[0].sql,
"SELECT id, candidate_plate FROM tab_scheduling_notifications WHERE suggestion_id = ? AND status != 'cancelled' LIMIT 1",
);
assert.deepEqual(calls[0].params, ["sug-1"]);
assert.equal(calls[0].via, "execute");
assert.equal(
calls[1].sql,
"INSERT INTO tab_scheduling_notifications (suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status) VALUES (?, ?, ?, ?, ?, 'sent')",
);
assert.deepEqual(calls[1].params, ["sug-1", "粤A1", "粤B2", "u-1", "张三"]);
assert.equal(calls[2].sql, "SELECT * FROM tab_scheduling_notifications WHERE id = ?");
assert.deepEqual(calls[2].params, [5]);
});
test("干预登记:已有生效记录返回 409 且不写库", async () => {
const { db, calls } = createMockDb([[{ id: 9, candidate_plate: "粤C3" }]]);
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestionId: "sug-1", currentPlate: "粤A1", candidatePlate: "粤B2" }),
});
assert.equal(res.status, 409);
assert.equal(calls.length, 1, "被阻断后不应再 INSERT");
const payload = await res.json() as { message: string };
assert.equal(payload.message.includes("粤C3"), true);
});
test("干预登记:缺少必要参数返回 400 且不查库", async () => {
const { db, calls } = createMockDb();
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestionId: "sug-1" }),
});
assert.equal(res.status, 400);
assert.equal(calls.length, 0);
});
test("历史列表:状态过滤与条数上限,SQL 与参数顺序锁定", async () => {
const { db, calls } = createMockDb([[]]);
const app = appWith(registerNotifyRoutes, db);
await app.request("/?status=sent&limit=9999");
assert.equal(calls.length, 1);
assert.equal(calls[0].via, "query", "列表沿用 query(非 execute)");
assert.equal(
calls[0].sql,
"SELECT * FROM tab_scheduling_notifications WHERE status = ? ORDER BY created_at DESC LIMIT ?",
);
assert.deepEqual(calls[0].params, ["sent", 500], "limit 上限 500");
calls.length = 0;
await app.request("/");
assert.equal(calls[0].sql, "SELECT * FROM tab_scheduling_notifications ORDER BY created_at DESC LIMIT ?");
assert.deepEqual(calls[0].params, [200], "无过滤时默认 200");
});
test("状态更新:UPDATE 字段顺序与回读 SQL 保持", async () => {
const { db, calls } = createMockDb([{}, [NOTIFICATION_ROW]]);
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/5", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "executed" }),
});
assert.equal(res.status, 200);
assert.equal(calls[0].via, "execute");
assert.equal(calls[0].sql.startsWith("UPDATE tab_scheduling_notifications SET "), true);
assert.equal(calls[0].sql.endsWith("WHERE id = ?"), true);
assert.equal(calls[0].params[calls[0].params.length - 1], 5);
assert.equal(calls[1].sql, "SELECT * FROM tab_scheduling_notifications WHERE id = ?");
});
test("状态更新:非法 id / 缺 status / 非法 status 一律 400 且不查库", async () => {
const { db, calls } = createMockDb();
const app = appWith(registerNotifyRoutes, db);
const cases: Array<[string, unknown]> = [
["/0", { status: "executed" }],
["/5", {}],
["/5", { status: "nope" }],
];
for (const [url, body] of cases) {
const res = await app.request(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
assert.equal(res.status, 400, `${url} ${JSON.stringify(body)}`);
}
assert.equal(calls.length, 0);
});
test("状态更新:记录不存在返回 404", async () => {
const { db } = createMockDb([{}, []]);
const app = appWith(registerNotifyRoutes, db);
const res = await app.request("/5", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "cancelled" }),
});
assert.equal(res.status, 404);
});
test("活跃映射与近 7 天计数:SQL 与映射键保持", async () => {
const { db, calls } = createMockDb([
[{ id: 1, suggestion_id: "sug-1", candidate_plate: "粤B2", status: "sent", created_at: "2026-07-01 10:00:00" }],
[{ cnt: "4" }],
]);
const map = await fetchActiveNotificationMap(db);
assert.equal(map.get("sug-1::粤B2")?.id, 1);
assert.equal(
calls[0].sql,
"SELECT id, suggestion_id, candidate_plate, status, created_at FROM tab_scheduling_notifications WHERE status != 'cancelled' ORDER BY created_at DESC",
);
assert.equal(await fetchRecentInterventionCount(db), 4);
assert.equal(
calls[1].sql,
"SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications WHERE status != 'cancelled' AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)",
);
});
test("建议列表:五条基础查询的 SQL 与顺序保持(不查外部 API 的路径)", async () => {
const { db, calls } = createMockDb([[], [], [], [], []]);
const app = appWith(registerSuggestionsRoutes, db, STUB_DEPS);
// targetId 让响应可预测;OneOS 段在无车牌时不会触发
const res = await app.request("/?targetId=1");
assert.equal(res.status, 200);
const sqls = calls.map(c => c.sql);
assert.equal(sqls[0], "SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id");
assert.equal(sqls[1].startsWith("SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,"), true);
assert.equal(sqls[1].includes("FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0"), true);
assert.equal(sqls[2].includes("FROM vehicle_info vi LEFT JOIN vehicle_status vs"), true);
assert.equal(sqls[2].includes("COALESCE(vs.operation_status, '') <> '5'"), true);
assert.equal(sqls[3], "SELECT plate_number, province, city FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL");
assert.equal(sqls[4].includes("COALESCE(vs.operation_status, '') IN ('3','4')"), true, "库存车辆条件保持");
});
test("分层:调度域的路由文件不再直接书写 SQL", () => {
const dir = path.dirname(fileURLToPath(import.meta.url));
for (const file of ["notify.ts", "suggestions.ts"]) {
const source = readFileSync(path.join(dir, file), "utf8");
const offenders = source.match(/\b(SELECT|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
assert.deepEqual(offenders, [], `${file} 的 SQL 必须留在 repository.ts`);
}
});
+44 -40
View File
@@ -1,11 +1,19 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mysqlPool from '../../db/mysql.js';
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
import { mapRegion } from '../vehicles.js';
import { mapRegion } from '../vehicles/model.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js';
import { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js';
import {
loadAssessmentTargets,
loadAssessmentVehicles,
loadInventoryVehicles,
loadRealtimeLocations,
loadVehicleTypes,
type Database,
} from './repository.js';
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js';
import type { AuthUser } from '../../auth/types.js';
@@ -64,17 +72,28 @@ function recentCompletedDates(count: number): string[] {
// Route
// ---------------------------------------------------------------------------
const app = new Hono();
export interface SuggestionsDependencies {
db: Database;
/**
* 跨域数据源(里程域的车辆信息与 OneOS 里程)。
* 显式作为依赖注入:既让本域可独立测试,也让"建议依赖里程数据"这件事在类型上可见。
*/
loadVehicleInfoMap?: typeof fetchVehicleInfoMap;
loadOneOsMileageDates?: typeof fetchOneOsMileageDates;
}
app.get('/', async (c) => {
export function registerSuggestionsRoutes(app: Hono, deps: SuggestionsDependencies): void {
const { db } = deps;
const loadVehicleInfoMap = deps.loadVehicleInfoMap ?? fetchVehicleInfoMap;
const loadOneOsMileageDates = deps.loadOneOsMileageDates ?? fetchOneOsMileageDates;
app.get('/', async (c) => {
try {
const targetIdParam = c.req.query('targetId');
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
// ---- Query 1: Assessment targets ----
const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown];
const targets = await loadAssessmentTargets(db);
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
for (const t of targets) {
@@ -85,26 +104,13 @@ app.get('/', async (c) => {
}
// ---- Query 2: Assessment vehicles ----
const [assessmentRows] = await pool.execute(`
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown];
const assessmentRows = await loadAssessmentVehicles(db);
// ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap();
const vehicleInfoMap = await loadVehicleInfoMap();
// ---- Query 4: Vehicle types from vehicle_info ----
const [truckTypeRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'
`) as [any[], unknown];
const truckTypeRows = await loadVehicleTypes(db);
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
for (const row of truckTypeRows) {
@@ -115,11 +121,7 @@ app.get('/', async (c) => {
}
// ---- Query 5: Real-time location ----
const [locationRows] = await pool.execute(`
SELECT plate_number, province, city
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL
`) as [any[], unknown];
const locationRows = await loadRealtimeLocations(db);
const locationMap = new Map<string, { province: string; city: string }>();
for (const row of locationRows) {
@@ -139,7 +141,7 @@ app.get('/', async (c) => {
const dates = recentCompletedDates(30);
const sevenDayDates = new Set(dates.slice(-7));
const allowedPlates = new Set(allPlates);
const rowsByDate = await fetchOneOsMileageDates(dates);
const rowsByDate = await loadOneOsMileageDates(dates);
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
for (const [date, rows] of rowsByDate) {
for (const row of rows) {
@@ -182,15 +184,7 @@ app.get('/', async (c) => {
}
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') IN ('3','4')
AND COALESCE(vs.vehicle_status, '') <> '4'
`) as [any[], unknown];
const inventoryTruckRows = await loadInventoryVehicles(db);
// ---- Build assessment vehicle lookup for inventory cross-reference ----
const assessmentByPlate = new Map<string, any>();
@@ -311,7 +305,7 @@ app.get('/', async (c) => {
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
// ---- Attach notification status to candidates ----
const notificationMap = await fetchActiveNotificationMap();
const notificationMap = await fetchActiveNotificationMap(db);
for (const s of suggestions) {
for (const c of s.candidates) {
const key = `${s.id}::${c.plateNumber}`;
@@ -365,7 +359,7 @@ app.get('/', async (c) => {
// Recalculate summary based on permission-filtered results
const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length;
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
const recentInterventionCount = await fetchRecentInterventionCount();
const recentInterventionCount = await fetchRecentInterventionCount(db);
const filteredSummary: SchedulingSummary = {
qualifiedCount: filteredQualified,
hopelessCount: filteredHopeless,
@@ -396,4 +390,14 @@ app.get('/', async (c) => {
}
});
}
/** 生产用路由器:绑定真实连接池。 */
export function createSuggestionsRouter(): Hono {
const app = new Hono();
registerSuggestionsRoutes(app, { db: mysqlPool });
return app;
}
const app = createSuggestionsRouter();
export default app;
-234
View File
@@ -1,234 +0,0 @@
import { Hono } from 'hono';
import pool from '../db.js';
import heatmapPool from '../heatmap-db.js';
import { getCache } from './mileage/cache.js';
import {
buildVehicleGrid,
buildVehicleRanking,
filterVehicleRecordsByRadius,
parseNearbyQuery,
parseVehicleHeatmapQuery,
summarizeVehicleRecords,
vehicleGridPrecision,
type VehicleHeatmapRecord,
} from './vehicle-heatmap-model.js';
type MetaRow = {
start_date: string;
end_date: string;
location_count: string;
vehicle_count: string;
day_count: string;
total_location_count: string;
excluded_location_count: string;
outside_mainland_count: string;
tibet_count: string;
};
type VehicleOptionRow = {
vin: string;
plate_number: string;
};
type RecordRow = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
source_record_id: string;
};
type BatchModelRow = {
target_name: string;
plate_number: string;
};
async function loadBatchModelPlates(): Promise<Map<string, Set<string>>> {
const cached = getCache()?.targetPlatesMap;
if (cached?.size) return cached;
try {
const [rows] = await pool.execute(`
select t.target_name, v.plate_number
from lingniu_prod.tab_mileage_assessment_target t
join lingniu_prod.tab_mileage_assessment_vehicle v
on v.target_id = t.id and v.is_deleted = 0
where t.is_deleted = 0
`) as [BatchModelRow[], unknown];
const result = new Map<string, Set<string>>();
for (const row of rows) {
const plates = result.get(row.target_name) || new Set<string>();
plates.add(row.plate_number);
result.set(row.target_name, plates);
}
return result;
} catch (error) {
console.error('[vehicle-heatmap] batch model lookup failed', error);
return new Map();
}
}
async function loadRecords(
startDate: string,
endDate: string,
query: string,
batchModel: string,
): Promise<VehicleHeatmapRecord[]> {
let batchPlates: string[] = [];
if (batchModel) {
const modelPlates = await loadBatchModelPlates();
batchPlates = [...(modelPlates.get(batchModel) || [])];
if (batchPlates.length === 0) return [];
}
const result = await heatmapPool.query<RecordRow>(`
select
to_char(stat_date, 'YYYY-MM-DD') as date,
vin,
plate_number as plate,
to_char(first_event_time at time zone 'Asia/Shanghai',
'YYYY-MM-DD HH24:MI:SS') as time,
longitude,
latitude,
source_name as source,
coalesce(source_record_id::text, '') as source_record_id
from analytics.vehicle_daily_first_location
where stat_date between $1::date and $2::date
and is_heatmap_eligible
and ($4::boolean = false or plate_number = any($5::text[]))
and (
$3 = ''
or upper(vin) like '%' || upper($3) || '%'
or upper(plate_number) like '%' || upper($3) || '%'
)
order by stat_date, vin
`, [startDate, endDate, query.trim(), Boolean(batchModel), batchPlates]);
return result.rows.map((row) => ({
...row,
longitude: Number(row.longitude),
latitude: Number(row.latitude),
sourceRecordId: row.source_record_id,
}));
}
const router = new Hono();
router.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) {
return c.json({ error: '高德地图配置缺失' }, 503);
}
return c.json({ key, securityCode });
});
router.get('/meta', async (c) => {
const [metaResult, vehiclesResult, batchModelPlates] = await Promise.all([
heatmapPool.query<MetaRow>(`
select
to_char(min(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as start_date,
to_char(max(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as end_date,
count(*) filter (where is_heatmap_eligible)::text as location_count,
count(distinct vin) filter (where is_heatmap_eligible)::text as vehicle_count,
count(distinct stat_date) filter (where is_heatmap_eligible)::text as day_count,
count(*)::text as total_location_count,
count(*) filter (where not is_heatmap_eligible)::text as excluded_location_count,
count(*) filter (where exclusion_reason = 'outside_mainland_china')::text as outside_mainland_count,
count(*) filter (where exclusion_reason = 'tibet')::text as tibet_count
from analytics.vehicle_daily_first_location
`),
heatmapPool.query<VehicleOptionRow>(`
select distinct on (vin) vin, plate_number
from analytics.vehicle_daily_first_location
where is_heatmap_eligible
order by vin, stat_date desc
`),
loadBatchModelPlates(),
]);
const meta = metaResult.rows[0];
const vehicles = vehiclesResult.rows
.map(({ vin, plate_number: plateNumber }) => ({ vin, plateNumber }))
.sort((left, right) => left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'));
return c.json({
startDate: meta.start_date,
endDate: meta.end_date,
locationCount: Number(meta.location_count),
vehicleCount: Number(meta.vehicle_count),
dayCount: Number(meta.day_count),
totalLocationCount: Number(meta.total_location_count),
excludedLocationCount: Number(meta.excluded_location_count),
outsideMainlandCount: Number(meta.outside_mainland_count),
tibetCount: Number(meta.tibet_count),
batchModels: [...batchModelPlates.keys()].sort((left, right) => left.localeCompare(right, 'zh-CN')),
vehicles,
});
});
router.get('/points', async (c) => {
const {
startDate,
endDate,
query,
batchModel,
metric,
} = parseVehicleHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
batchModel: c.req.query('batchModel'),
metric: c.req.query('metric'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const records = await loadRecords(startDate, endDate, query, batchModel);
const precision = vehicleGridPrecision(startDate, endDate, query, batchModel);
const { points, max } = buildVehicleGrid(records, metric, precision);
const summary = summarizeVehicleRecords(records);
return c.json({
startDate,
endDate,
metric,
locationCount: summary.locationCount,
vehicleCount: summary.vehicleCount,
dayCount: summary.dayCount,
points,
max,
topVehicles: buildVehicleRanking(records),
});
});
router.get('/nearby', async (c) => {
const { center, radiusKm } = parseNearbyQuery({
lng: c.req.query('lng'),
lat: c.req.query('lat'),
radiusKm: c.req.query('radiusKm'),
});
if (!center) {
return c.json({ error: '经纬度参数无效' }, 400);
}
const { startDate, endDate, query, batchModel } = parseVehicleHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
batchModel: c.req.query('batchModel'),
});
const records = await loadRecords(startDate, endDate, query, batchModel);
const nearby = filterVehicleRecordsByRadius(records, center, radiusKm);
const summary = summarizeVehicleRecords(nearby);
return c.json({
center,
radiusKm,
locationCount: summary.locationCount,
vehicleCount: summary.vehicleCount,
topVehicles: buildVehicleRanking(nearby),
});
});
export default router;
@@ -11,7 +11,12 @@ import {
summarizeVehicleRecords,
vehicleGridPrecision,
type VehicleHeatmapRecord,
} from './vehicle-heatmap-model.js';
} from './model.js';
import { recentDayRange } from '../../../shared/date-range.js';
// 默认区间是"近 30 天"滚动窗口,注入固定时钟让断言可复现。
const NOW = new Date('2026-07-13T12:00:00');
const FALLBACK = recentDayRange(30, NOW);
function record(overrides: Partial<VehicleHeatmapRecord> = {}): VehicleHeatmapRecord {
return {
@@ -27,10 +32,10 @@ function record(overrides: Partial<VehicleHeatmapRecord> = {}): VehicleHeatmapRe
};
}
test('车辆热力图查询继续使用固定默认日期和原有字符串处理规则', () => {
assert.deepEqual(parseVehicleHeatmapQuery({}), {
startDate: '2026-01-01',
endDate: '2026-07-13',
test('车辆热力图查询默认使用近 30 天滚动窗口,并保留原有字符串处理规则', () => {
assert.deepEqual(parseVehicleHeatmapQuery({}, NOW), {
startDate: FALLBACK.start,
endDate: FALLBACK.end,
query: '',
batchModel: '',
metric: 'locations',
@@ -41,9 +46,9 @@ test('车辆热力图查询继续使用固定默认日期和原有字符串处
query: ' 粤A ',
batchModel: ' 190台冷链 ',
metric: 'vehicles',
}), {
}, NOW), {
startDate: '2026-06-01',
endDate: '2026-07-13',
endDate: FALLBACK.end,
query: ' 粤A ',
batchModel: '190台冷链',
metric: 'vehicles',
@@ -1,3 +1,4 @@
import { recentDayRange } from '../../../shared/date-range.js';
export type VehicleHeatmapMetric = 'locations' | 'vehicles';
export type VehicleHeatmapRecord = {
@@ -33,17 +34,22 @@ type NearbyQueryInput = {
radiusKm?: string;
};
export const VEHICLE_HEATMAP_DEFAULT_START = '2026-01-01';
export const VEHICLE_HEATMAP_DEFAULT_END = '2026-07-13';
/**
* 未显式传日期时的兜底窗口。
* 此前写死为 2026-01-01 ~ 2026-07-13,会让默认查询永久指向一段历史数据;
* 现改为"近 N 天"滚动窗口。
*/
export const HEATMAP_DEFAULT_WINDOW_DAYS = 30;
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput) {
export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput, now: Date = new Date()) {
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS, now);
return {
startDate: isDate(input.startDate) ? input.startDate : VEHICLE_HEATMAP_DEFAULT_START,
endDate: isDate(input.endDate) ? input.endDate : VEHICLE_HEATMAP_DEFAULT_END,
startDate: isDate(input.startDate) ? input.startDate : fallback.start,
endDate: isDate(input.endDate) ? input.endDate : fallback.end,
query: input.query || '',
batchModel: (input.batchModel || '').trim(),
metric: input.metric === 'vehicles' ? 'vehicles' as const : 'locations' as const,
@@ -0,0 +1,142 @@
import type { VehicleHeatmapRecord } from './model.js';
/**
* 车辆热力图的数据访问。
*
* 该域同时使用两个库:主业务库(MySQL,考核批次 → 车牌映射)与车辆位置库
* (PostgreSQL 只读,定位点)。两者的 query 返回形状不同,因此分别声明依赖,
* 并由 routes.test.ts 的契约测试锁定 SQL 与参数。
*/
/** MySQL 连接:只声明用到的能力。 */
export interface MysqlDatabase {
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
/** PostgreSQL 连接:pg 的返回形状是 { rows }。 */
export interface PgDatabase {
query<T = any>(sql: string, values?: any[]): Promise<{ rows: T[] }>;
}
export type MetaRow = {
start_date: string;
end_date: string;
location_count: string;
vehicle_count: string;
day_count: string;
total_location_count: string;
excluded_location_count: string;
outside_mainland_count: string;
tibet_count: string;
};
export type VehicleOptionRow = {
vin: string;
plate_number: string;
};
type RecordRow = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
source_record_id: string;
};
type BatchModelRow = {
target_name: string;
plate_number: string;
};
/** 考核批次 → 车牌集合。 */
export async function loadBatchModelPlates(mysql: MysqlDatabase): Promise<Map<string, Set<string>>> {
const [rows] = await mysql.execute(`
select t.target_name, v.plate_number
from lingniu_prod.tab_mileage_assessment_target t
join lingniu_prod.tab_mileage_assessment_vehicle v
on v.target_id = t.id and v.is_deleted = 0
where t.is_deleted = 0
`) as [BatchModelRow[], unknown];
const result = new Map<string, Set<string>>();
for (const row of rows) {
const plates = result.get(row.target_name) || new Set<string>();
plates.add(row.plate_number);
result.set(row.target_name, plates);
}
return result;
}
/** 与既有实现一致:把首行直接当作 MetaRow(缺行时由调用方行为保持不变)。 */
export async function loadMeta(pg: PgDatabase): Promise<MetaRow> {
const metaResult = await pg.query<MetaRow>(`
select
to_char(min(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as start_date,
to_char(max(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as end_date,
count(*) filter (where is_heatmap_eligible)::text as location_count,
count(distinct vin) filter (where is_heatmap_eligible)::text as vehicle_count,
count(distinct stat_date) filter (where is_heatmap_eligible)::text as day_count,
count(*)::text as total_location_count,
count(*) filter (where not is_heatmap_eligible)::text as excluded_location_count,
count(*) filter (where exclusion_reason = 'outside_mainland_china')::text as outside_mainland_count,
count(*) filter (where exclusion_reason = 'tibet')::text as tibet_count
from analytics.vehicle_daily_first_location
`);
return metaResult.rows[0] as MetaRow;
}
export async function loadVehicleOptions(pg: PgDatabase): Promise<VehicleOptionRow[]> {
const vehiclesResult = await pg.query<VehicleOptionRow>(`
select distinct on (vin) vin, plate_number
from analytics.vehicle_daily_first_location
where is_heatmap_eligible
order by vin, stat_date desc
`);
return vehiclesResult.rows;
}
/**
* 逐车每日首个有效定位点。
* `$4` 沿用原实现的语义——是否选择了考核批次(而不是"车牌集合是否为空"),
* 调用方需在批次无车牌时提前返回,不要靠这里兜底。
*/
export async function loadRecords(
pg: PgDatabase,
args: { startDate: string; endDate: string; query: string; batchModelSelected: boolean; batchPlates: string[] },
): Promise<VehicleHeatmapRecord[]> {
const result = await pg.query<RecordRow>(`
select
to_char(stat_date, 'YYYY-MM-DD') as date,
vin,
plate_number as plate,
to_char(first_event_time at time zone 'Asia/Shanghai',
'YYYY-MM-DD HH24:MI:SS') as time,
longitude,
latitude,
source_name as source,
coalesce(source_record_id::text, '') as source_record_id
from analytics.vehicle_daily_first_location
where stat_date between $1::date and $2::date
and is_heatmap_eligible
and ($4::boolean = false or plate_number = any($5::text[]))
and (
$3 = ''
or upper(vin) like '%' || upper($3) || '%'
or upper(plate_number) like '%' || upper($3) || '%'
)
order by stat_date, vin
`, [args.startDate, args.endDate, args.query.trim(), args.batchModelSelected, args.batchPlates]);
return result.rows.map((row) => ({
date: row.date,
vin: row.vin,
plate: row.plate,
time: row.time,
longitude: Number(row.longitude),
latitude: Number(row.latitude),
source: row.source,
sourceRecordId: row.source_record_id,
}));
}
@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
import { Hono } from "hono";
import { registerVehicleHeatmapRoutes, type VehicleHeatmapDependencies } from "./routes.js";
import type { MysqlDatabase, PgDatabase } from "./repository.js";
interface Call {
sql: string;
params: unknown[];
}
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
function createMysql(results: unknown[] = []): { db: MysqlDatabase; calls: Call[] } {
const calls: Call[] = [];
const queue = [...results];
return {
calls,
db: {
async execute(sql: string, params?: any[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return [queue.shift() ?? [], []] as never;
},
},
};
}
function createPg(results: { rows: unknown[] }[] = []): { db: PgDatabase; calls: Call[] } {
const calls: Call[] = [];
const queue = [...results];
return {
calls,
db: {
async query(sql: string, params?: any[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return (queue.shift() ?? { rows: [] }) as never;
},
},
};
}
function makeApp(deps: VehicleHeatmapDependencies): Hono {
const app = new Hono();
registerVehicleHeatmapRoutes(app, deps);
return app;
}
const META_ROW = {
start_date: "2026-01-01",
end_date: "2026-07-13",
location_count: "10",
vehicle_count: "3",
day_count: "5",
total_location_count: "12",
excluded_location_count: "2",
outside_mainland_count: "1",
tibet_count: "1",
};
const RECORD_ROW = {
date: "2026-07-01",
vin: "VIN1",
plate: "粤A1",
time: "2026-07-01 08:00:00",
longitude: "113.1",
latitude: "23.2",
source: "gps",
source_record_id: "r1",
};
const EMPTY_BATCH_PLATES = [[], []];
test("/config:缺少高德配置时返回 503", async () => {
const prevKey = process.env.AMAP_WEB_KEY;
const prevCode = process.env.AMAP_SECURITY_JS_CODE;
delete process.env.AMAP_WEB_KEY;
delete process.env.AMAP_SECURITY_JS_CODE;
try {
const app = makeApp({ mysql: createMysql().db, pg: createPg().db });
const res = await app.request("/config");
assert.equal(res.status, 503);
} finally {
if (prevKey !== undefined) process.env.AMAP_WEB_KEY = prevKey;
if (prevCode !== undefined) process.env.AMAP_SECURITY_JS_CODE = prevCode;
}
});
test("/meta:两条定位库查询与批次映射的 SQL 原样保留", async () => {
const mysql = createMysql([[]]);
const pg = createPg([{ rows: [META_ROW] }, { rows: [{ vin: "VIN2", plate_number: "粤B2" }] }]);
// /meta issues: loadMeta, loadVehicleOptions (parallel) + batch plates
const app = makeApp({ mysql: mysql.db, pg: pg.db, cachedBatchPlates: () => new Map([["B1", new Set(["粤A1"])]]) });
const res = await app.request("/meta");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.deepEqual(body, {
startDate: "2026-01-01",
endDate: "2026-07-13",
locationCount: 10,
vehicleCount: 3,
dayCount: 5,
totalLocationCount: 12,
excludedLocationCount: 2,
outsideMainlandCount: 1,
tibetCount: 1,
batchModels: ["B1"],
vehicles: [{ vin: "VIN2", plateNumber: "粤B2" }],
});
assert.equal(pg.calls.length, 2);
assert.equal(
pg.calls[0].sql,
"select to_char(min(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as start_date, to_char(max(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as end_date, count(*) filter (where is_heatmap_eligible)::text as location_count, count(distinct vin) filter (where is_heatmap_eligible)::text as vehicle_count, count(distinct stat_date) filter (where is_heatmap_eligible)::text as day_count, count(*)::text as total_location_count, count(*) filter (where not is_heatmap_eligible)::text as excluded_location_count, count(*) filter (where exclusion_reason = 'outside_mainland_china')::text as outside_mainland_count, count(*) filter (where exclusion_reason = 'tibet')::text as tibet_count from analytics.vehicle_daily_first_location",
);
assert.equal(pg.calls[1].sql.includes("select distinct on (vin) vin, plate_number"), true);
assert.equal(mysql.calls.length, 0, "命中缓存的批次映射不应查主库");
});
test("/meta:缓存缺失时查主库取批次车牌,失败按空映射降级", async () => {
const mysql = createMysql([[{ target_name: "B1", plate_number: "粤A1" }, { target_name: "B1", plate_number: "粤A2" }]]);
const pg = createPg([{ rows: [META_ROW] }, { rows: [] }]);
const app = makeApp({ mysql: mysql.db, pg: pg.db });
const res = await app.request("/meta");
assert.equal(res.status, 200);
assert.equal(mysql.calls.length, 1);
assert.equal(
mysql.calls[0].sql,
"select t.target_name, v.plate_number from lingniu_prod.tab_mileage_assessment_target t join lingniu_prod.tab_mileage_assessment_vehicle v on v.target_id = t.id and v.is_deleted = 0 where t.is_deleted = 0",
);
});
test("/points:日期倒置返回 400 且不查定位库", async () => {
const mysql = createMysql();
const pg = createPg();
const app = makeApp({ mysql: mysql.db, pg: pg.db });
const res = await app.request("/points?startDate=2026-07-10&endDate=2026-07-01");
assert.equal(res.status, 400);
assert.equal(pg.calls.length, 0);
});
test("/points:定位点查询的 $4/$5 语义与参数顺序保持不变", async () => {
const mysql = createMysql();
const pg = createPg([{ rows: [RECORD_ROW] }]);
const app = makeApp({ mysql: mysql.db, pg: pg.db });
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&query=%20VIN1%20&metric=locations");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.locationCount, 1);
assert.equal(body.vehicleCount, 1);
assert.equal(body.metric, "locations");
assert.equal(Array.isArray(body.points), true);
assert.deepEqual(body.topVehicles, [{ vin: "VIN1", plateNumber: "粤A1", locationCount: 1, firstSeen: RECORD_ROW.time, lastSeen: RECORD_ROW.time }]);
assert.equal(pg.calls.length, 1);
assert.equal(pg.calls[0].sql.includes("from analytics.vehicle_daily_first_location where stat_date between $1::date and $2::date"), true);
assert.equal(pg.calls[0].sql.includes("and ($4::boolean = false or plate_number = any($5::text[]))"), true);
assert.deepEqual(pg.calls[0].params, ["2026-07-01", "2026-07-02", "VIN1", false, []]);
});
test("/points:选择批次但批次无车牌时直接返回空,不查定位库", async () => {
const mysql = createMysql([[]]); // batch model exists but has no plates
const pg = createPg();
const app = makeApp({ mysql: mysql.db, pg: pg.db });
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&batchModel=unknown");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.locationCount, 0);
assert.deepEqual(body.points, []);
assert.equal(pg.calls.length, 0, "无车牌时不应查定位库");
});
test("/points:选定批次时 $4=true 且 $5 为车牌数组", async () => {
const mysql = createMysql([[]]);
const pg = createPg([{ rows: [] }]);
const app = makeApp({
mysql: mysql.db,
pg: pg.db,
cachedBatchPlates: () => new Map([["B1", new Set(["粤A1", "粤A2"])]]),
});
await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&batchModel=B1");
assert.deepEqual(pg.calls[0].params, ["2026-07-01", "2026-07-02", "", true, ["粤A1", "粤A2"]]);
});
test("/nearby:非法经纬度返回 400", async () => {
const app = makeApp({ mysql: createMysql().db, pg: createPg().db });
const res = await app.request("/nearby?lng=abc&lat=23");
assert.equal(res.status, 400);
});
test("/nearby:半径过滤与排名口径不变", async () => {
const pg = createPg([{ rows: [
{ ...RECORD_ROW, longitude: "113.1", latitude: "23.2" },
{ ...RECORD_ROW, vin: "VIN2", source_record_id: "r2", longitude: "120.0", latitude: "30.0" },
] }]);
const app = makeApp({ mysql: createMysql().db, pg: pg.db });
const res = await app.request("/nearby?lng=113.1&lat=23.2&radiusKm=5&startDate=2026-07-01&endDate=2026-07-02");
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
assert.equal(body.locationCount, 1, "只保留半径内的点");
assert.equal(body.vehicleCount, 1);
assert.equal(body.radiusKm, 5);
});
test("分层:路由文件不再直接书写 SQL", () => {
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
const offenders = source.match(/\b(select|insert into|insert ignore|update |delete from|create table|alter table)\b/gi) ?? [];
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts(注意本域 SQL 为小写)");
});
+169
View File
@@ -0,0 +1,169 @@
import { Hono } from 'hono';
import mysqlPool from '../../db/mysql.js';
import heatmapPool from '../../db/heatmap.js';
import { getCache } from '../mileage/cache.js';
import {
buildVehicleGrid,
buildVehicleRanking,
filterVehicleRecordsByRadius,
parseNearbyQuery,
parseVehicleHeatmapQuery,
summarizeVehicleRecords,
vehicleGridPrecision,
type VehicleHeatmapRecord,
} from './model.js';
import {
loadBatchModelPlates,
loadMeta,
loadRecords,
loadVehicleOptions,
type MysqlDatabase,
type PgDatabase,
} from './repository.js';
export interface VehicleHeatmapDependencies {
mysql: MysqlDatabase;
pg: PgDatabase;
/** 里程缓存里的考核批次车牌映射(可命中热缓存,避免每次查主库)。 */
cachedBatchPlates?: () => Map<string, Set<string>> | undefined;
}
export function registerVehicleHeatmapRoutes(app: Hono, deps: VehicleHeatmapDependencies): void {
const { mysql, pg } = deps;
/**
* 批次 → 车牌映射。优先用里程缓存;缓存缺失时查主库。
* 查询失败按"无批次数据"降级,不影响其他筛选(与既有行为一致)。
*/
async function resolveBatchModelPlates(): Promise<Map<string, Set<string>>> {
const cached = deps.cachedBatchPlates?.() ?? getCache()?.targetPlatesMap;
if (cached?.size) return cached;
try {
return await loadBatchModelPlates(mysql);
} catch (error) {
console.error('[vehicle-heatmap] batch model lookup failed', error);
return new Map();
}
}
async function collectRecords(
startDate: string,
endDate: string,
query: string,
batchModel: string,
): Promise<VehicleHeatmapRecord[]> {
let batchPlates: string[] = [];
if (batchModel) {
const modelPlates = await resolveBatchModelPlates();
batchPlates = [...(modelPlates.get(batchModel) || [])];
if (batchPlates.length === 0) return [];
}
return loadRecords(pg, {
startDate,
endDate,
query,
batchModelSelected: Boolean(batchModel),
batchPlates,
});
}
app.get('/config', (c) => {
const key = process.env.AMAP_WEB_KEY;
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
if (!key || !securityCode) {
return c.json({ error: '高德地图配置缺失' }, 503);
}
return c.json({ key, securityCode });
});
app.get('/meta', async (c) => {
const [meta, vehicleRows, batchModelPlates] = await Promise.all([
loadMeta(pg),
loadVehicleOptions(pg),
resolveBatchModelPlates(),
]);
const vehicles = vehicleRows
.map(({ vin, plate_number: plateNumber }) => ({ vin, plateNumber }))
.sort((left, right) => left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'));
return c.json({
startDate: meta.start_date,
endDate: meta.end_date,
locationCount: Number(meta.location_count),
vehicleCount: Number(meta.vehicle_count),
dayCount: Number(meta.day_count),
totalLocationCount: Number(meta.total_location_count),
excludedLocationCount: Number(meta.excluded_location_count),
outsideMainlandCount: Number(meta.outside_mainland_count),
tibetCount: Number(meta.tibet_count),
batchModels: [...batchModelPlates.keys()].sort((left, right) => left.localeCompare(right, 'zh-CN')),
vehicles,
});
});
app.get('/points', async (c) => {
const { startDate, endDate, query, batchModel, metric } = parseVehicleHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
batchModel: c.req.query('batchModel'),
metric: c.req.query('metric'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const records = await collectRecords(startDate, endDate, query, batchModel);
const precision = vehicleGridPrecision(startDate, endDate, query, batchModel);
const { points, max } = buildVehicleGrid(records, metric, precision);
const summary = summarizeVehicleRecords(records);
return c.json({
startDate,
endDate,
metric,
locationCount: summary.locationCount,
vehicleCount: summary.vehicleCount,
dayCount: summary.dayCount,
points,
max,
topVehicles: buildVehicleRanking(records),
});
});
app.get('/nearby', async (c) => {
const { center, radiusKm } = parseNearbyQuery({
lng: c.req.query('lng'),
lat: c.req.query('lat'),
radiusKm: c.req.query('radiusKm'),
});
if (!center) {
return c.json({ error: '经纬度参数无效' }, 400);
}
const { startDate, endDate, query, batchModel } = parseVehicleHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
batchModel: c.req.query('batchModel'),
});
const records = await collectRecords(startDate, endDate, query, batchModel);
const nearby = filterVehicleRecordsByRadius(records, center, radiusKm);
const summary = summarizeVehicleRecords(nearby);
return c.json({
center,
radiusKm,
locationCount: summary.locationCount,
vehicleCount: summary.vehicleCount,
topVehicles: buildVehicleRanking(nearby),
});
});
}
/** 生产用路由器:绑定真实连接池。 */
export function createVehicleHeatmapRouter(): Hono {
const app = new Hono();
registerVehicleHeatmapRoutes(app, { mysql: mysqlPool, pg: heatmapPool });
return app;
}
const app = createVehicleHeatmapRouter();
export default app;
+1 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import router from '../vehicles.js';
import router from './routes.js';
import { vehicleRepository, type FlowDetailRow } from './repository.js';
import type { Vehicle } from '../../types.js';
@@ -26,7 +26,7 @@ function vehicle(id: string): Vehicle {
}
test('keeps all vehicle GET endpoints in their established order', () => {
const source = readFileSync(new URL('../vehicles.ts', import.meta.url), 'utf8');
const source = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8');
const paths = Array.from(source.matchAll(/app\.get\('([^']+)'/g), (match) => match[1]);
assert.deepEqual(paths, [
+1 -1
View File
@@ -1,4 +1,4 @@
import pool from '../../db.js';
import pool from '../../db/mysql.js';
import type { Vehicle, VehicleRow } from '../../types.js';
import { transformRow } from './model.js';
import type { WeeklyTruckIds } from './types.js';
@@ -7,9 +7,9 @@ import type {
BatchSummary,
BatchGroup,
InventoryTypeSummary,
} from '../types.js';
import { filterByPermission, maskCustomerNames, maskCustomerName } from '../auth/permissions.js';
import type { AuthUser } from '../auth/types.js';
} from '../../types.js';
import { filterByPermission, maskCustomerNames, maskCustomerName } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js';
import type { Context } from 'hono';
import {
INVENTORY_REGIONS,
@@ -21,7 +21,7 @@ import {
mapInventoryRegion,
mapMacroRegion,
resolveCity,
} from './vehicles/model.js';
} from './model.js';
import {
compareDepartmentNames,
countByType,
@@ -29,10 +29,10 @@ import {
getStats,
listDateRange,
normalizeDateRange,
} from './vehicles/utils.js';
import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './vehicles/repository.js';
} from './utils.js';
import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './repository.js';
export { mapRegion } from './vehicles/model.js';
export { mapRegion } from './model.js';
const app = new Hono();