feat(assets): 展示近七天资产趋势并支持业务回溯归档
This commit is contained in:
@@ -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 })) };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user