feat(assets): 展示近七天资产趋势并支持业务回溯归档
ci/woodpecker/push/woodpecker Pipeline was canceled
ci/woodpecker/manual/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-11-02 18:17:41 +08:00
parent 650abe049f
commit a81ace14e4
10 changed files with 68 additions and 55 deletions
+23 -18
View File
@@ -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 })) };
}
}