From a81ace14e48d7eda5e8c1677a1abe5548dae728f Mon Sep 17 00:00:00 2001 From: shishengliang Date: Mon, 2 Nov 2026 18:17:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(assets):=20=E5=B1=95=E7=A4=BA=E8=BF=91?= =?UTF-8?q?=E4=B8=83=E5=A4=A9=E8=B5=84=E4=BA=A7=E8=B6=8B=E5=8A=BF=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=9A=E5=8A=A1=E5=9B=9E=E6=BA=AF=E5=BD=92?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20261102_asset_history_business.sql | 1 + .../assets/history/AssetHistorySection.tsx | 27 ++++++------ .../assets/history/HistoryDetailsDialog.tsx | 14 +++---- src/modules/assets/history/api.ts | 2 +- src/modules/assets/history/presentation.ts | 2 +- src/server/routes/asset-history/model.ts | 15 +++---- src/server/routes/asset-history/service.ts | 41 +++++++++++-------- src/server/routes/asset-history/store.ts | 8 ++-- src/server/routes/vehicles/model.ts | 6 ++- src/shared/assets/history.ts | 7 +++- 10 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 migrations/20261102_asset_history_business.sql diff --git a/migrations/20261102_asset_history_business.sql b/migrations/20261102_asset_history_business.sql new file mode 100644 index 0000000..2a83e50 --- /dev/null +++ b/migrations/20261102_asset_history_business.sql @@ -0,0 +1 @@ +ALTER TABLE bi_asset_history_batches MODIFY COLUMN source ENUM('AUTO','IMPORT','业务回溯') NOT NULL; diff --git a/src/modules/assets/history/AssetHistorySection.tsx b/src/modules/assets/history/AssetHistorySection.tsx index 401e6c0..0defe29 100644 --- a/src/modules/assets/history/AssetHistorySection.tsx +++ b/src/modules/assets/history/AssetHistorySection.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState, type MouseEvent } from 'react'; import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts'; import { ChevronRight, RefreshCw } from 'lucide-react'; -import { HISTORY_STATE_LABELS, historyTime, shanghaiMonth, type HistoryMode, type HistoryPoint, type HistoryTrend } from '../../../shared/assets/history'; +import { HISTORY_STATE_LABELS, historyTime, shanghaiMonth, shanghaiDay, type HistoryMode, type HistoryPoint, type HistoryTrend } from '../../../shared/assets/history'; import { fetchHistoryLive, fetchHistoryTrend } from './api'; import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation'; import { HistoryDetailsDialog } from './HistoryDetailsDialog'; @@ -9,7 +9,7 @@ import { HistorySelect } from './HistorySelect'; import './history.css'; export function AssetHistorySection({ subject, onSubjects }: { subject: string | null; onSubjects?: (names: string[]) => void }) { - const [mode, setMode] = useState('monthly'); + const [mode, setMode] = useState('daily'); const [year, setYear] = useState(() => Number(shanghaiMonth(new Date()).slice(0, 4))); const [data, setData] = useState(null); const [selected, setSelected] = useState(null); @@ -23,7 +23,8 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string | const abort = new AbortController(); let current: HistoryTrend | null = null; let busy = false; - let observedMonth = shanghaiMonth(new Date()); + const calendarKey = mode === 'daily' ? shanghaiDay : shanghaiMonth; + let observedMonth = calendarKey(new Date()); setData(null); setSelected(null); setDetails(null); setError(''); setLoading(true); const load = async () => { busy = true; @@ -38,7 +39,7 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string | void load(); const interval = setInterval(async () => { if (busy || !current || abort.signal.aborted) return; - const month = shanghaiMonth(new Date()); + const month = calendarKey(new Date()); if (month !== observedMonth) { observedMonth = month; await load(); return; } if (current.points.some(p => p.state === 'CAPTURING')) { await load(); return; } if (!current.points.some(p => p.state === 'LIVE')) return; @@ -64,26 +65,26 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string | }; return

资产历史趋势

-
{(['annual', 'monthly'] as const).map(value => )}
+
{(['daily', 'monthly', 'annual'] as const).map(value => )}
{mode === 'monthly' && ({ value: String(y), label: `${y}年` }))} onChange={value => { setYear(Number(value)); setDetails(null); }} />} - {data && { setSelected(value); setDetails(null); }} options={data.points.map(p => ({ value: p.period, label: p.label, detail: p.state === 'RECONSTRUCTED' ? '估算' : p.state === 'LIVE' ? '实时' : p.state === 'ARCHIVED' ? '归档' : p.state === 'FUTURE' ? '未来' : p.state === 'MISSING' ? '缺失' : HISTORY_STATE_LABELS[p.state] }))} />} + {data && { setSelected(value); setDetails(null); }} options={data.points.map(p => ({ value: p.period, label: p.label, detail: p.source === '业务回溯' ? '业务回溯' : p.state === 'RECONSTRUCTED' ? '估算' : p.state === 'LIVE' ? '实时' : p.state === 'ARCHIVED' ? '归档' : p.state === 'FUTURE' ? '未来' : p.state === 'MISSING' ? '缺失' : HISTORY_STATE_LABELS[p.state] }))} />} 每日 23:55
{loading && !data ?

正在加载资产历史趋势…

: !data ?

{error || '暂无可用数据'}

: <> {error &&

{error} · 保留上次查询结果

} -
+
- { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}> + { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}> { const item = payload?.[0]?.payload as HistoryPoint | undefined; const values = item && displayHistoryCounts(item); - return active && item ?
{item.label} · {HISTORY_STATE_LABELS[item.state]}{values ?

总数 {values.total ?? '待核实'} · 运营 {values.operating} · 库存 {values.inventory}

:

暂无数量

}
: null; + return active && item ?
{item.label} · {item.source === '业务回溯' ? '业务回溯' : HISTORY_STATE_LABELS[item.state]}{values ?

总数 {values.total ?? '待核实'} · 运营 {values.operating} · 库存 {values.inventory}

:

暂无数量

}
: null; }} /> @@ -91,9 +92,9 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
- + {point &&
-
{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}{HISTORY_STATE_LABELS[point.state]}
+
{mode !== 'monthly' || point.state === 'LIVE' ? point.label : `${point.label}末`}{point.source === '业务回溯' ? '业务回溯' : HISTORY_STATE_LABELS[point.state]}
{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) =>
{label}{displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}
)}
{hasComparison ?
{point.comparisonLabel}
{(['total', 'inventory', 'operating'] as const).map(key => { const difference = displayHistoryDifference(point, key); @@ -101,10 +102,10 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string | })}
: null}
{point.asOf && }{!hasComparison && {point.comparisonLabel}}
- {mode === 'monthly' && } + {mode !== 'annual' && }
} -

历史库存状态快照缺失,核对中,暂不展示。

+ {data.points.some(p => p.state === 'MISSING') &&

历史库存状态快照缺失,核对中,暂不展示。

} } {details && }
; diff --git a/src/modules/assets/history/HistoryDetailsDialog.tsx b/src/modules/assets/history/HistoryDetailsDialog.tsx index 6ffb14a..5fabaaa 100644 --- a/src/modules/assets/history/HistoryDetailsDialog.tsx +++ b/src/modules/assets/history/HistoryDetailsDialog.tsx @@ -24,25 +24,25 @@ export function HistoryDetailsDialog({ point, subject, close }: { point: History useEffect(() => { const abort = new AbortController(); setLoading(true); setError(''); setData(null); - fetchHistoryDetails(point.month, point.batchId, subject, category, query, page, abort.signal) + fetchHistoryDetails(point.period, point.batchId, subject, category, query, page, abort.signal) .then(result => { if (!abort.signal.aborted) setData(result); }) .catch(e => { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '明细加载失败'); }) .finally(() => { if (!abort.signal.aborted) setLoading(false); }); return () => abort.abort(); - }, [point.month, point.batchId, subject, category, query, page, retry]); + }, [point.period, point.batchId, subject, category, query, page, retry]); const pages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1; return createPortal(
{ if (e.target === e.currentTarget) close(); }}>
-

{point.month.replace('-', '年')}月资产明细

-

{subject || '全部公司'} · {HISTORY_STATE_LABELS[point.state]} · {point.reconstruction ? '参考时点' : point.state === 'ARCHIVED' ? '快照' : '截至'} {historyTime(point.reconstruction ? point.scheduledFor : data?.point.asOf || point.asOf)}

-
+

{point.period.length === 10 ? point.period : point.month.replace('-', '年') + '月'}资产明细

+

{subject || '全部公司'} · {point.source === '业务回溯' ? '业务回溯' : HISTORY_STATE_LABELS[point.state]} · {point.reconstruction ? '参考时点' : point.state === 'ARCHIVED' ? '快照' : '截至'} {historyTime(point.reconstruction ? point.scheduledFor : data?.point.asOf || point.asOf)}

+
-
- {loading ?

正在加载月度明细…

: error ?

{error}

: data && <> + {loading ?

正在加载资产明细…

: error ?

{error}

: data && <>

{data.point.reconstruction ? '以下为核对表中的交还记录推算车辆,归属未知项留空。历史总数待核实,不能将两类相加当作历史资产总数。' : data.point.state === 'ARCHIVED' ? '以下为当时归档的车辆及归属信息。' : '以下为最新实时明细,数量可能随业务更新。'} 当前范围:总数 {displayHistoryCounts(data.point)?.total ?? '待核实'} · 运营 {displayHistoryCounts(data.point)?.operating} · 库存 {displayHistoryCounts(data.point)?.inventory}

{data.items.length ? <>
{data.items.map(item => diff --git a/src/modules/assets/history/api.ts b/src/modules/assets/history/api.ts index 8c1766e..25037ed 100644 --- a/src/modules/assets/history/api.ts +++ b/src/modules/assets/history/api.ts @@ -16,5 +16,5 @@ export function fetchHistoryLive(mode: HistoryMode, subject: string | null, sign return get('live', { mode, subject }, signal); } export function fetchHistoryDetails(month: string, batchId: string | null, subject: string | null, category: HistoryCategory, search: string, page: number, signal: AbortSignal) { - return get('details', { month, batchId, subject, category, search, page, pageSize: 30 }, signal); + return get('details', { [month.length === 10 ? 'day' : 'month']: month, batchId, subject, category, search, page, pageSize: 30 }, signal); } diff --git a/src/modules/assets/history/presentation.ts b/src/modules/assets/history/presentation.ts index 626a26b..4bd78c5 100644 --- a/src/modules/assets/history/presentation.ts +++ b/src/modules/assets/history/presentation.ts @@ -17,7 +17,7 @@ export function updateLivePoint(points: HistoryPoint[], live: HistoryPoint): His total: live.counts.total - baseline.total, operating: live.counts.operating - baseline.operating, inventory: live.counts.inventory - baseline.inventory, - } : null, comparisonLabel: baseline ? `截至当前较${live.period.length === 4 ? '上年末' : '上月末'}` : '暂无可比数据' }; + } : null, comparisonLabel: baseline ? `截至当前较${live.period.length === 4 ? '上年末' : live.period.length === 10 ? '昨日' : '上月末'}` : '暂无可比数据' }; }); } export const signedCount = (value: number) => value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString(); diff --git a/src/server/routes/asset-history/model.ts b/src/server/routes/asset-history/model.ts index f9aff34..39e5a66 100644 --- a/src/server/routes/asset-history/model.ts +++ b/src/server/routes/asset-history/model.ts @@ -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, diff --git a/src/server/routes/asset-history/service.ts b/src/server/routes/asset-history/service.ts index b4d0b28..95bdab4 100644 --- a/src/server/routes/asset-history/service.ts +++ b/src/server/routes/asset-history/service.ts @@ -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, 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) { 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): Promise { 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): Promise { 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 })) }; } } diff --git a/src/server/routes/asset-history/store.ts b/src/server/routes/asset-history/store.ts index a186d46..50c679a 100644 --- a/src/server/routes/asset-history/store.ts +++ b/src/server/routes/asset-history/store.ts @@ -7,7 +7,7 @@ import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type export interface HistoryReader { settings(): Promise; - batches(): Promise; + batches(days?: readonly string[]): Promise; vehicles(batchIds: string[]): Promise>; } 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 { - const rows = await this.read(`SELECT id, LEFT(period_key,7) AS month, status, source, + async batches(days?: readonly string[]): Promise { + 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(); diff --git a/src/server/routes/vehicles/model.ts b/src/server/routes/vehicles/model.ts index 95ec822..36919e7 100644 --- a/src/server/routes/vehicles/model.ts +++ b/src/server/routes/vehicles/model.ts @@ -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 { diff --git a/src/shared/assets/history.ts b/src/shared/assets/history.ts index 0110e20..7466f11 100644 --- a/src/shared/assets/history.ts +++ b/src/shared/assets/history.ts @@ -1,4 +1,4 @@ -export type HistoryMode = 'monthly' | 'annual'; +export type HistoryMode = 'daily' | 'monthly' | 'annual'; export type HistoryState = 'LIVE' | 'ARCHIVED' | 'MISSING' | 'FUTURE' | 'CAPTURING' | 'FAILED' | 'RECONSTRUCTED'; export type HistoryCategory = 'all' | 'operating' | 'inventory'; export interface HistoryCounts { total: number; operating: number; inventory: number } @@ -9,7 +9,7 @@ export interface HistoryPoint { scheduledFor: string; asOf: string | null; state: HistoryState; - source: 'AUTO' | 'IMPORT' | 'LIVE' | 'RECONSTRUCTED' | null; + source: 'AUTO' | 'IMPORT' | '业务回溯' | 'LIVE' | 'RECONSTRUCTED' | null; batchId: string | null; counts: HistoryCounts | null; difference: HistoryCounts | null; @@ -69,6 +69,9 @@ export function dayCutoff(day: string): Date { if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(date.getTime()) || shanghaiDay(date) !== day) throw new Error('日期格式无效'); return date; } +export function historyCutoff(period: string): Date { + return period.length === 10 ? dayCutoff(period) : monthCutoff(period); +} export function shiftDay(day: string, offset: number): string { return shanghaiDay(new Date(dayCutoff(day).getTime() + offset * 86_400_000)); }
车辆分类公司 / 部门负责人 / 客户
{item.plateNumber || '未登记车牌'}{item.model}VIN:{item.vin || '未提供'}{item.reconstructionBasis && 推算依据:{item.reconstructionBasis}}