feat(assets): 展示近七天资产趋势并支持业务回溯归档
This commit is contained in:
@@ -2,15 +2,16 @@ import { filterAssetVehiclesByPermission, maskCustomerNames } from '../../auth/p
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||
import { isOperatingVehicle, isInventoryVehicle, transformRow } from '../vehicles/model.js';
|
||||
import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiTimestamp, type HistoryCounts, type HistoryPoint } from '../../../shared/assets/history.js';
|
||||
import { CAPTURE_WINDOW_MS, historyCutoff, shanghaiTimestamp, type HistoryCounts, type HistoryPoint } from '../../../shared/assets/history.js';
|
||||
|
||||
export interface HistoryVehicle extends Vehicle {
|
||||
businessBasis?: string;
|
||||
vehicleStatus: string | null;
|
||||
disposition: 'operating' | 'inventory';
|
||||
}
|
||||
export interface HistoryBatch {
|
||||
id: string; month: string; status: 'COMPLETE' | 'FAILED' | 'MISSED';
|
||||
source: 'AUTO' | 'IMPORT'; scheduledFor: string; capturedAt: string | null;
|
||||
source: 'AUTO' | 'IMPORT' | '业务回溯'; scheduledFor: string; capturedAt: string | null;
|
||||
counts: HistoryCounts;
|
||||
}
|
||||
export interface HistorySettings { enabledAt: string }
|
||||
@@ -47,9 +48,9 @@ export function counts(vehicles: Vehicle[]): HistoryCounts {
|
||||
return { total: vehicles.length, operating: vehicles.filter(isOperatingVehicle).length, inventory: vehicles.filter(isInventoryVehicle).length };
|
||||
}
|
||||
export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?: HistoryVehicle[], live?: HistoryVehicle[], annual = false): HistoryPoint {
|
||||
const cutoff = batch ? new Date(batch.scheduledFor) : monthCutoff(month);
|
||||
const cutoff = batch ? new Date(batch.scheduledFor) : historyCutoff(month);
|
||||
let state: HistoryPoint['state'] = 'MISSING';
|
||||
if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, 7)) ? 'LIVE' : 'FUTURE';
|
||||
if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, month.length)) ? 'LIVE' : 'FUTURE';
|
||||
else if (batch?.status === 'COMPLETE') state = 'ARCHIVED';
|
||||
else if (batch?.status === 'FAILED') state = 'FAILED';
|
||||
else if (now.getTime() < cutoff.getTime() + CAPTURE_WINDOW_MS && batch?.status !== 'MISSED') state = 'CAPTURING';
|
||||
@@ -57,9 +58,9 @@ export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?:
|
||||
const pointCounts = state === 'ARCHIVED' ? counts(data || []) : state === 'LIVE' ? counts(live || []) : null;
|
||||
return {
|
||||
period: annual ? month.slice(0, 4) : month,
|
||||
label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month,
|
||||
label: annual ? `${month.slice(0, 4)}年` : month.length === 10 ? month.slice(5).replace('-', '/') : `${Number(month.slice(5))}月`, month: month.slice(0, 7),
|
||||
scheduledFor: batch?.scheduledFor || shanghaiTimestamp(cutoff),
|
||||
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? batch!.capturedAt : null,
|
||||
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? (batch!.source === '业务回溯' ? batch!.scheduledFor : batch!.capturedAt) : null,
|
||||
state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null,
|
||||
batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts,
|
||||
difference: null, comparisonLabel: '暂无可比数据', detailAvailable: available,
|
||||
@@ -75,7 +76,7 @@ export function comparePoint(point: HistoryPoint, previous: HistoryPoint | undef
|
||||
} : null } } : {}),
|
||||
comparisonLabel: comparable ? `推算值较${annual ? '上年年末' : '上月末'}` : '推算与实时或正式快照口径不同,暂无可比数据' };
|
||||
}
|
||||
const label = annual ? '上年末' : '上月末';
|
||||
const label = annual ? '上年末' : point.period.length === 10 ? '昨日' : '上月末';
|
||||
return { ...point, difference: point.counts && previous?.counts ? {
|
||||
total: point.counts.total - previous.counts.total,
|
||||
inventory: point.counts.inventory - previous.counts.inventory,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type { Vehicle } from '../../types.js';
|
||||
import { isOperatingVehicle } from '../vehicles/model.js';
|
||||
import { HISTORY_STATE_LABELS, nextCapture, shanghaiMonth, shiftMonth, monthCutoff, type HistoryMode, type HistoryTrend, type HistoryCategory, type HistoryDetails } from '../../../shared/assets/history.js';
|
||||
import { HISTORY_STATE_LABELS, nextCapture, shanghaiMonth, shiftMonth, historyCutoff, shiftDay, shanghaiDay, type HistoryMode, type HistoryTrend, type HistoryCategory, type HistoryDetails } from '../../../shared/assets/history.js';
|
||||
import { authorizedVehicles, comparePoint, counts, freezeLive, HistoryStorageUnavailable, makePoint, validateVehicles, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
|
||||
import type { HistoryReader } from './store.js';
|
||||
import { reconstructionItems, reconstructionPoint, type ReconstructionReader } from './reconstruction.js';
|
||||
@@ -11,7 +11,7 @@ export class HistoryPeriodUnavailable extends Error {}
|
||||
export function parseHistoryQuery(params: Record<string, string>, now: Date) {
|
||||
const mode = params.mode || 'monthly';
|
||||
const year = Number(params.year || shanghaiMonth(now).slice(0, 4));
|
||||
if (!['monthly', 'annual'].includes(mode) || !Number.isInteger(year) || year < 2000 || year > Number(shanghaiMonth(now).slice(0, 4))) throw new HistoryInputError('年份或统计方式无效');
|
||||
if (!['daily', 'monthly', 'annual'].includes(mode) || !Number.isInteger(year) || year < 2000 || year > Number(shanghaiMonth(now).slice(0, 4))) throw new HistoryInputError('年份或统计方式无效');
|
||||
if ((params.subject || '').length > 500) throw new HistoryInputError('公司筛选无效');
|
||||
return { mode: mode as HistoryMode, year, subject: params.subject?.trim() || undefined };
|
||||
}
|
||||
@@ -27,23 +27,24 @@ export class HistoryService {
|
||||
async livePoint(user: AuthUser, params: Record<string, string>) {
|
||||
const now = this.clock();
|
||||
const query = parseHistoryQuery(params, now);
|
||||
const month = query.mode === 'annual' ? `${shanghaiMonth(now).slice(0, 4)}-12` : shanghaiMonth(now);
|
||||
const rows = now < monthCutoff(month) ? authorizedVehicles(freezeLive(await this.live()), user, query.subject) : [];
|
||||
const month = query.mode === 'daily' ? shanghaiDay(now) : query.mode === 'annual' ? `${shanghaiMonth(now).slice(0, 4)}-12` : shanghaiMonth(now);
|
||||
const rows = now < historyCutoff(month) ? authorizedVehicles(freezeLive(await this.live()), user, query.subject) : [];
|
||||
return makePoint(month, now, undefined, [], rows, query.mode === 'annual');
|
||||
}
|
||||
|
||||
private async archives(months: string[], catalog?: HistoryBatch[]) {
|
||||
private async archives(months: string[], allowBusiness: boolean, catalog?: HistoryBatch[]) {
|
||||
let settings: HistorySettings | null = null;
|
||||
let batches: HistoryBatch[] = [];
|
||||
try {
|
||||
[settings, batches] = await Promise.all([this.store.settings(), catalog ? Promise.resolve(catalog) : this.store.batches()]);
|
||||
[settings, batches] = await Promise.all([this.store.settings(), catalog ? Promise.resolve(catalog) : this.store.batches(months[0].length === 10 ? months : undefined)]);
|
||||
batches = batches.filter(b => allowBusiness || b.source !== '业务回溯');
|
||||
const selected = batches.filter(b => b.status === 'COMPLETE' && months.includes(b.month));
|
||||
const vehicles = await this.store.vehicles(selected.map(b => b.id));
|
||||
for (const batch of selected) {
|
||||
const rows = vehicles.get(batch.id) || [];
|
||||
validateVehicles(rows);
|
||||
const totals = counts(rows);
|
||||
if (!batch.capturedAt || Object.entries(totals).some(([key, value]) => value !== batch.counts[key as keyof typeof totals]) || rows.some(v => v.disposition !== (isOperatingVehicle(v) ? 'operating' : 'inventory'))) throw new Error('Snapshot integrity mismatch');
|
||||
if ((!batch.capturedAt && batch.source !== '业务回溯') || Object.entries(totals).some(([key, value]) => value !== batch.counts[key as keyof typeof totals]) || rows.some(v => v.disposition !== (isOperatingVehicle(v) ? 'operating' : 'inventory'))) throw new Error('Snapshot integrity mismatch');
|
||||
}
|
||||
return { settings, batches, vehicles, ready: true };
|
||||
} catch (error) {
|
||||
@@ -54,20 +55,21 @@ export class HistoryService {
|
||||
async trend(user: AuthUser, params: Record<string, string>): Promise<HistoryTrend> {
|
||||
const now = this.clock();
|
||||
const query = parseHistoryQuery(params, now);
|
||||
const days = query.mode === 'daily' ? Array.from({ length: 8 }, (_, i) => shiftDay(shanghaiDay(now), i - 7)) : undefined;
|
||||
const currentYear = Number(shanghaiMonth(now).slice(0, 4));
|
||||
const reconstruction = await this.reconstructionData(user, query.subject);
|
||||
// Metadata is small (one batch/month); only relevant detail batches are loaded.
|
||||
let catalog: HistoryBatch[] = [];
|
||||
try { catalog = await this.store.batches(); } catch (e) { if (!(e instanceof HistoryStorageUnavailable)) throw e; }
|
||||
try { catalog = await this.store.batches(days); } catch (e) { if (!(e instanceof HistoryStorageUnavailable)) throw e; }
|
||||
const firstYear = Math.min(currentYear - 4, query.year, ...catalog.map(b => Number(b.month.slice(0, 4))), ...(reconstruction?.periods.map(p => Number(p.month.slice(0, 4))) || []));
|
||||
const years = Array.from({ length: currentYear - firstYear + 1 }, (_, i) => currentYear - i);
|
||||
const months = query.mode === 'monthly'
|
||||
const months = days ? days.slice(1) : query.mode === 'monthly'
|
||||
? Array.from({ length: 12 }, (_, i) => `${query.year}-${String(i + 1).padStart(2, '0')}`)
|
||||
: [...years].reverse().map(y => `${y}-12`);
|
||||
const previous = query.mode === 'monthly' ? shiftMonth(months[0], -1) : `${firstYear - 1}-12`;
|
||||
const previous = days ? days[0] : query.mode === 'monthly' ? shiftMonth(months[0], -1) : `${firstYear - 1}-12`;
|
||||
const range = [previous, ...months];
|
||||
const hasLive = months.some(m => monthCutoff(m) > now && (query.mode === 'annual' ? m.startsWith(String(currentYear)) : m === shanghaiMonth(now)));
|
||||
const [archive, rawLive] = await Promise.all([this.archives(range, catalog), hasLive ? this.live() : Promise.resolve([])]);
|
||||
const hasLive = months.some(m => historyCutoff(m) > now && (query.mode === 'annual' ? m.startsWith(String(currentYear)) : m === (days ? shanghaiDay(now) : shanghaiMonth(now))));
|
||||
const [archive, rawLive] = await Promise.all([this.archives(range, user.permissionLevel === 'full' && !query.subject, catalog), hasLive ? this.live() : Promise.resolve([])]);
|
||||
const live = freezeLive(rawLive);
|
||||
const subjects = new Set(authorizedVehicles(live, user).map(v => v.subjectOrg).filter((s): s is string => !!s));
|
||||
const byMonth = new Map(archive.batches.map(batch => [batch.month, batch]));
|
||||
@@ -76,7 +78,7 @@ export class HistoryService {
|
||||
const batch = byMonth.get(month);
|
||||
const rows = batch?.status === 'COMPLETE' ? archive.vehicles.get(batch.id)! : [];
|
||||
const point = makePoint(month, now, batch, authorizedVehicles(rows, user, query.subject), authorizedVehicles(live, user, query.subject), query.mode === 'annual');
|
||||
if (point.state === 'CAPTURING' && (!archive.settings || new Date(archive.settings.enabledAt) > monthCutoff(month))) point.state = 'MISSING';
|
||||
if (point.state === 'CAPTURING' && (!archive.settings || new Date(archive.settings.enabledAt) > historyCutoff(month))) point.state = 'MISSING';
|
||||
if (point.state === 'MISSING' && reconstruction) return reconstructionPoint(reconstruction, month, query.mode === 'annual', now) || point;
|
||||
return point;
|
||||
});
|
||||
@@ -89,15 +91,18 @@ export class HistoryService {
|
||||
}
|
||||
async details(user: AuthUser, params: Record<string, string>): Promise<HistoryDetails> {
|
||||
const now = this.clock();
|
||||
const month = params.month || '';
|
||||
if (!/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) throw new HistoryInputError('月份无效');
|
||||
const month = params.day || params.month || '';
|
||||
if (params.day) {
|
||||
try { if (params.day.length !== 10) throw new Error(); historyCutoff(params.day); }
|
||||
catch { throw new HistoryInputError('日期无效'); }
|
||||
} else if (!/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) throw new HistoryInputError('月份无效');
|
||||
parseHistoryQuery({ ...params, year: month.slice(0, 4) }, now);
|
||||
const category = params.category || 'all';
|
||||
const page = Number(params.page || '1'), pageSize = Number(params.pageSize || '50');
|
||||
if (!['all', 'operating', 'inventory'].includes(category) || !Number.isSafeInteger(page) || page < 1 || page > 100000 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100 || (params.search || '').length > 100 || (params.batchId || '').length > 36) throw new HistoryInputError('明细筛选或分页参数无效');
|
||||
const archive = await this.archives([month]);
|
||||
const archive = await this.archives([month], user.permissionLevel === 'full' && !params.subject?.trim());
|
||||
const batch = archive.batches.find(b => b.month === month);
|
||||
const isCurrent = month === shanghaiMonth(now) && now < monthCutoff(month);
|
||||
const isCurrent = month === (params.day ? shanghaiDay(now) : shanghaiMonth(now)) && now < historyCutoff(month);
|
||||
if (!isCurrent && makePoint(month, now, batch).state === 'MISSING') {
|
||||
const reconstruction = await this.reconstructionData(user, params.subject?.trim());
|
||||
const point = reconstruction && reconstructionPoint(reconstruction, month, false, now);
|
||||
@@ -121,6 +126,6 @@ export class HistoryService {
|
||||
return { point, counts: counts(scoped), total: filtered.length, page, pageSize,
|
||||
items: filtered.slice((page - 1) * pageSize, page * pageSize).map(v => ({ id: v.id, plateNumber: v.plateNumber, vin: v.vin, type: v.type, model: v.model,
|
||||
disposition: v.disposition, operationStatus: v.operationStatus, vehicleStatus: v.vehicleStatus, subjectOrg: v.subjectOrg,
|
||||
departmentName: v.departmentName, customerManager: v.customerManager, customerName: v.customerName, contractNo: v.contractNo })) };
|
||||
departmentName: v.departmentName, customerManager: v.customerManager, customerName: v.customerName, contractNo: v.contractNo, reconstructionBasis: v.businessBasis })) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type
|
||||
|
||||
export interface HistoryReader {
|
||||
settings(): Promise<HistorySettings | null>;
|
||||
batches(): Promise<HistoryBatch[]>;
|
||||
batches(days?: readonly string[]): Promise<HistoryBatch[]>;
|
||||
vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>>;
|
||||
}
|
||||
export type CaptureResult = 'captured' | 'already-complete' | 'locked' | 'outside-window';
|
||||
@@ -29,11 +29,11 @@ export class MysqlHistoryStore implements HistoryReader {
|
||||
const rows = await this.read(`SELECT ${dateColumn('daily_enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
|
||||
return rows[0]?.enabledAt ? { enabledAt: rows[0].enabledAt } : null;
|
||||
}
|
||||
async batches(): Promise<HistoryBatch[]> {
|
||||
const rows = await this.read(`SELECT id, LEFT(period_key,7) AS month, status, source,
|
||||
async batches(days?: readonly string[]): Promise<HistoryBatch[]> {
|
||||
const rows = await this.read(`SELECT id, ${days ? 'period_key' : 'LEFT(period_key,7)'} AS month, status, source,
|
||||
total_count,operating_count,inventory_count,
|
||||
${dateColumn('scheduled_for')} AS scheduledFor, ${dateColumn('captured_at')} AS capturedAt
|
||||
FROM bi_asset_history_batches WHERE definition_version=? AND (CHAR_LENGTH(period_key)=7 OR period_key=DATE_FORMAT(LAST_DAY(CONCAT(LEFT(period_key,7),'-01')), '%Y-%m-%d')) ORDER BY period_key`, [HISTORY_VERSION]);
|
||||
FROM bi_asset_history_batches WHERE (definition_version=? OR (definition_version='asset-business-v1' AND source='业务回溯')) AND ${days ? 'period_key BETWEEN ? AND ? AND CHAR_LENGTH(period_key)=10' : "(CHAR_LENGTH(period_key)=7 OR period_key=DATE_FORMAT(LAST_DAY(CONCAT(LEFT(period_key,7),'-01')), '%Y-%m-%d'))"} ORDER BY (source='业务回溯') DESC, period_key`, days ? [HISTORY_VERSION, days[0], days[days.length - 1]] : [HISTORY_VERSION]);
|
||||
const batches = rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt,
|
||||
counts: { total: row.total_count, operating: row.operating_count, inventory: row.inventory_count } }));
|
||||
const byMonth = new Map<string, HistoryBatch>();
|
||||
|
||||
@@ -66,11 +66,13 @@ export function mapStatus(operationStatus: string | null, vehicleStatus: string
|
||||
|
||||
/** Operational category is independent from movement/delivery workflow status. */
|
||||
export function isOperatingVehicle(vehicle: Vehicle): boolean {
|
||||
return ['1', '2', '租赁', '自营'].includes((vehicle.operationStatus || '').trim());
|
||||
return ['1', '2', '租赁', '自营'].includes((vehicle.operationStatus || '').trim())
|
||||
|| ('businessBasis' in vehicle && vehicle.operationStatus === '运营');
|
||||
}
|
||||
|
||||
export function isInventoryVehicle(vehicle: Vehicle): boolean {
|
||||
return ['3', '4', '可运营', '待运营', '在库'].includes((vehicle.operationStatus || '').trim());
|
||||
return ['3', '4', '可运营', '待运营', '在库'].includes((vehicle.operationStatus || '').trim())
|
||||
|| ('businessBasis' in vehicle && vehicle.operationStatus === '库存');
|
||||
}
|
||||
|
||||
export function isInventoryAbnormal(vehicle: Vehicle): boolean {
|
||||
|
||||
Reference in New Issue
Block a user