fix: restore post-v1.1.14 features while preserving asset updates
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:
+19
-6
@@ -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' }));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface AuthUser {
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
authMethod?: 'sso' | 'password';
|
||||
userId: string;
|
||||
userName: string;
|
||||
loginName: string;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,路由只做校验与组装");
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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)";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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")),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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®ion=%E5%98%89%E5%85%B4%E5%B8%82®ionGranularity=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/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,路由只做校验与组装");
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+12
-7
@@ -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',
|
||||
+7
-5
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
+12
-7
@@ -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',
|
||||
+11
-5
@@ -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 为小写)");
|
||||
});
|
||||
@@ -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,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,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();
|
||||
|
||||
Reference in New Issue
Block a user