127 lines
10 KiB
TypeScript
127 lines
10 KiB
TypeScript
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 { 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';
|
|
|
|
export class HistoryInputError extends Error {}
|
|
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 ((params.subject || '').length > 500) throw new HistoryInputError('公司筛选无效');
|
|
return { mode: mode as HistoryMode, year, subject: params.subject?.trim() || undefined };
|
|
}
|
|
export class HistoryService {
|
|
constructor(private readonly store: HistoryReader, private readonly live: () => Promise<Vehicle[]>, private readonly clock: () => Date = () => new Date(), private readonly reconstruction?: ReconstructionReader) {}
|
|
|
|
private async reconstructionData(user: AuthUser, subject?: string) {
|
|
// These reviewed records have no trustworthy historical owner/dept/company.
|
|
// Never grant their visibility from today's vehicle IDs or company values.
|
|
return this.reconstruction && user.permissionLevel === 'full' && !subject ? this.reconstruction.read() : null;
|
|
}
|
|
|
|
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) : [];
|
|
return makePoint(month, now, undefined, [], rows, query.mode === 'annual');
|
|
}
|
|
|
|
private async archives(months: string[], 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()]);
|
|
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');
|
|
}
|
|
return { settings, batches, vehicles, ready: true };
|
|
} catch (error) {
|
|
if (!(error instanceof HistoryStorageUnavailable)) throw error;
|
|
return { settings: null, batches: [], vehicles: new Map<string, HistoryVehicle[]>(), ready: false };
|
|
}
|
|
}
|
|
async trend(user: AuthUser, params: Record<string, string>): Promise<HistoryTrend> {
|
|
const now = this.clock();
|
|
const query = parseHistoryQuery(params, now);
|
|
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; }
|
|
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'
|
|
? 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 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 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]));
|
|
for (const rows of archive.vehicles.values()) for (const v of authorizedVehicles(rows, user)) if (v.subjectOrg) subjects.add(v.subjectOrg);
|
|
const points = range.map(month => {
|
|
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 === 'MISSING' && reconstruction) return reconstructionPoint(reconstruction, month, query.mode === 'annual', now) || point;
|
|
return point;
|
|
});
|
|
return { mode: query.mode, year: query.year, points: points.slice(1).map((p, i) => comparePoint(p, points[i], query.mode === 'annual')),
|
|
years, subjects: [...subjects].sort((a, b) => a.localeCompare(b, 'zh-CN')), storageReady: archive.ready,
|
|
enabledAt: archive.settings?.enabledAt || null, nextCaptureAt: archive.settings ? nextCapture(now) : null,
|
|
warning: [!archive.ready ? '正式历史快照存储尚未部署。' : !archive.settings ? '自动采集尚未启用。' : null,
|
|
this.reconstruction ? reconstruction ? '历史空白已按核对表回填业务推算值;历史总数待核实,不属于月末正式快照。回溯数据仅支持全量权限及全部公司。'
|
|
: '回溯估算缺少可靠历史主体和权限归属,仅全量权限在全部公司下可见;当前筛选保留正式快照或空白。' : '未采集历史期间留空。'].filter(Boolean).join(' ') || null };
|
|
}
|
|
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('月份无效');
|
|
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 batch = archive.batches.find(b => b.month === month);
|
|
const isCurrent = month === shanghaiMonth(now) && now < monthCutoff(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);
|
|
if (point) {
|
|
if (params.batchId && params.batchId !== point.batchId) throw new HistoryPeriodUnavailable('回溯版本已变化,请刷新趋势');
|
|
const period = reconstruction!.periods.find(p => p.month === month)!;
|
|
const search = (params.search || '').trim().toLocaleLowerCase();
|
|
const items = reconstructionItems(period).filter(v => (category === 'all' || v.disposition === category) && (!search || `${v.plateNumber} ${v.vin}`.toLocaleLowerCase().includes(search)))
|
|
.sort((a, b) => a.plateNumber.localeCompare(b.plateNumber, 'zh-CN') || a.id.localeCompare(b.id));
|
|
return { point, counts: null, total: items.length, page, pageSize, items: items.slice((page - 1) * pageSize, page * pageSize) };
|
|
}
|
|
}
|
|
if (params.batchId && (!batch || params.batchId !== batch.id || batch.status !== 'COMPLETE')) throw new HistoryPeriodUnavailable('所选快照不可用,请刷新趋势');
|
|
const rows = isCurrent ? freezeLive(await this.live()) : batch?.status === 'COMPLETE' ? archive.vehicles.get(batch.id)! : [];
|
|
const scoped = authorizedVehicles(rows, user, params.subject?.trim());
|
|
const point = makePoint(month, now, batch, scoped, scoped);
|
|
if (!point.detailAvailable) throw new HistoryPeriodUnavailable(HISTORY_STATE_LABELS[point.state]);
|
|
const search = (params.search || '').trim().toLocaleLowerCase();
|
|
const filtered = scoped.filter(v => (category === 'all' || v.disposition === category as HistoryCategory) && (!search || `${v.plateNumber} ${v.vin}`.toLocaleLowerCase().includes(search)))
|
|
.sort((a, b) => a.plateNumber.localeCompare(b.plateNumber, 'zh-CN') || a.id.localeCompare(b.id));
|
|
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 })) };
|
|
}
|
|
}
|