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, 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, 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) { 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(), ready: false }; } } async trend(user: AuthUser, params: Record): Promise { 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): Promise { 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 })) }; } }