feat(assets): add verified asset history trends and compact overview
This commit is contained in:
@@ -102,6 +102,10 @@ export default function AssetsModule() {
|
||||
// 所属公司(归属主体)筛选 —— 影响全页聚合
|
||||
const [selectedSubject, setSelectedSubject] = useState<string | null>(null);
|
||||
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
|
||||
const [historySubjects, setHistorySubjects] = useState<string[]>([]);
|
||||
const updateHistorySubjects = useCallback((names: string[]) => setHistorySubjects(names), []);
|
||||
const subjectOptions = useMemo(() => [...subjects, ...historySubjects.filter(name => !subjects.some(s => s.name === name))
|
||||
.map(name => ({ name, total: 0, operating: 0, inventory: 0, historicalOnly: true }))], [subjects, historySubjects]);
|
||||
const [subjectDropdownOpen, setSubjectDropdownOpen] = useState(false);
|
||||
const [subjectSearch, setSubjectSearch] = useState('');
|
||||
const subjectDropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -485,7 +489,7 @@ export default function AssetsModule() {
|
||||
loading={loading}
|
||||
selectedSubject={selectedSubject}
|
||||
setSelectedSubject={setSelectedSubject}
|
||||
subjects={subjects}
|
||||
subjects={subjectOptions}
|
||||
subjectDropdownOpen={subjectDropdownOpen}
|
||||
setSubjectDropdownOpen={setSubjectDropdownOpen}
|
||||
subjectSearch={subjectSearch}
|
||||
@@ -504,6 +508,8 @@ export default function AssetsModule() {
|
||||
|
||||
{tabReady && activeTab === 'overview' && (
|
||||
<OverviewView
|
||||
selectedSubject={selectedSubject}
|
||||
onHistorySubjects={updateHistorySubjects}
|
||||
summary={SUMMARY}
|
||||
operatingRate={operatingRate}
|
||||
inventoryRate={inventoryRate}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { fetchJson } from '../../auth/api-client';
|
||||
const BASE = '/api/vehicles';
|
||||
|
||||
export interface SubjectOption {
|
||||
historicalOnly?: boolean;
|
||||
name: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
|
||||
@@ -170,9 +170,9 @@ export function AssetsHeader({
|
||||
<span className="truncate">{s.name}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||
{s.total} 台
|
||||
{s.historicalOnly ? '历史主体' : <>{s.total} 台
|
||||
<span className="mx-1 text-gray-200">·</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span></>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AssetSummarySection } from './overview/AssetSummarySection';
|
||||
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||
import { AssetHistorySection } from '../history/AssetHistorySection';
|
||||
import type { OverviewViewProps } from './overview/types';
|
||||
|
||||
export function OverviewView(props: OverviewViewProps) {
|
||||
@@ -15,6 +16,8 @@ export function OverviewView(props: OverviewViewProps) {
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
|
||||
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
|
||||
|
||||
<FlowStatisticsCard
|
||||
flowRange={props.flowRange}
|
||||
setFlowRange={props.setFlowRange}
|
||||
|
||||
@@ -66,17 +66,17 @@ export function SummaryMetrics({
|
||||
|
||||
</div>
|
||||
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-2 divide-x divide-slate-100 text-center">
|
||||
<div className="px-2">
|
||||
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
<div className="text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
<div className="text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,8 @@ export type OverviewViewProps = SummaryMetricsProps
|
||||
& AssetSummarySectionProps
|
||||
& InventoryStatisticsSectionProps
|
||||
& {
|
||||
selectedSubject: string | null;
|
||||
onHistorySubjects: (names: string[]) => void;
|
||||
allTypesExpanded: boolean;
|
||||
toggleAllAssetTypes: () => void;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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 { fetchHistoryLive, fetchHistoryTrend } from './api';
|
||||
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation';
|
||||
import { HistoryDetailsDialog } from './HistoryDetailsDialog';
|
||||
import { HistorySelect } from './HistorySelect';
|
||||
import './history.css';
|
||||
|
||||
export function AssetHistorySection({ subject, onSubjects }: { subject: string | null; onSubjects?: (names: string[]) => void }) {
|
||||
const [mode, setMode] = useState<HistoryMode>('monthly');
|
||||
const [year, setYear] = useState(() => Number(shanghaiMonth(new Date()).slice(0, 4)));
|
||||
const [data, setData] = useState<HistoryTrend | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [details, setDetails] = useState<HistoryPoint | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const closeDetails = useCallback(() => setDetails(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
let current: HistoryTrend | null = null;
|
||||
let busy = false;
|
||||
let observedMonth = shanghaiMonth(new Date());
|
||||
setData(null); setSelected(null); setDetails(null); setError(''); setLoading(true);
|
||||
const load = async () => {
|
||||
busy = true;
|
||||
try {
|
||||
const result = await fetchHistoryTrend(mode, year, subject, abort.signal);
|
||||
if (abort.signal.aborted) return;
|
||||
current = result; setData(result); setSelected(old => old && result.points.some(p => p.period === old) ? old : defaultHistoryPeriod(result.points));
|
||||
onSubjects?.(result.subjects); setError('');
|
||||
} catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '趋势加载失败'); }
|
||||
finally { busy = false; if (!abort.signal.aborted) setLoading(false); }
|
||||
};
|
||||
void load();
|
||||
const interval = setInterval(async () => {
|
||||
if (busy || !current || abort.signal.aborted) return;
|
||||
const month = shanghaiMonth(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;
|
||||
busy = true;
|
||||
try {
|
||||
const live = await fetchHistoryLive(mode, subject, abort.signal);
|
||||
if (abort.signal.aborted) return;
|
||||
if (live.state !== 'LIVE') { await load(); return; }
|
||||
current = { ...current, points: updateLivePoint(current.points, live) }; setData(current); setError('');
|
||||
} catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '实时数据刷新失败'); }
|
||||
finally { busy = false; }
|
||||
}, 5000);
|
||||
return () => { abort.abort(); clearInterval(interval); };
|
||||
}, [mode, year, subject, retry, onSubjects]);
|
||||
|
||||
const point = data?.points.find(p => p.period === selected);
|
||||
const hasComparison = point && (['total', 'inventory', 'operating'] as const).some(key => displayHistoryDifference(point, key) != null);
|
||||
const chart = data?.points.map(p => ({ ...p, total: displayHistoryCounts(p)?.total ?? null, operating: displayHistoryCounts(p)?.operating ?? null, inventory: displayHistoryCounts(p)?.inventory ?? null })) || [];
|
||||
const selectBar = (entry: { originalDataIndex: number }, _index: number, event: MouseEvent<SVGPathElement>) => {
|
||||
event.stopPropagation();
|
||||
const item = chart[entry.originalDataIndex];
|
||||
if (item) setSelected(item.period);
|
||||
};
|
||||
return <section className="asset-history" aria-labelledby="asset-history-title" data-testid="asset-history">
|
||||
<div className="asset-history-controls"><header className="asset-history-header"><h2 id="asset-history-title">资产历史趋势</h2>
|
||||
<div className="asset-history-modes" role="group" aria-label="历史统计方式">{(['annual', 'monthly'] as const).map(value => <button type="button" key={value} aria-pressed={mode === value} onClick={() => { setMode(value); setDetails(null); }}>{value === 'annual' ? '年度' : '月度'}</button>)}</div>
|
||||
</header>
|
||||
<div className="asset-history-toolbar">
|
||||
{mode === 'monthly' && <HistorySelect label="历史年份" value={String(year)} options={(data?.years || [year]).map(y => ({ value: String(y), label: `${y}年` }))} onChange={value => { setYear(Number(value)); setDetails(null); }} />}
|
||||
{data && <HistorySelect label="统计期间" value={selected || ''} onChange={value => { 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] }))} />}
|
||||
<span title="每月最后一天北京时间17:00采集;年度复用12月快照">月末 17:00</span>
|
||||
<button type="button" className="asset-history-refresh" aria-label="刷新资产历史趋势" disabled={loading} onClick={() => setRetry(x => x + 1)}><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{loading && !data ? <p className="asset-history-empty" role="status">正在加载资产历史趋势…</p> : !data ? <p className="asset-history-empty" role="alert">{error || '暂无可用数据'} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : <>
|
||||
{error && <p className="asset-history-warning" role="alert">{error} · 保留上次查询结果 <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p>}
|
||||
<p className="asset-history-unit">数量(台)</p>
|
||||
<div className="asset-history-chart" role="img" aria-label="运营数与库存数堆叠柱,资产总数折线;精确数值和缺失状态可通过上方统计期间选择框查看">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}>
|
||||
<ComposedChart data={chart} margin={{ top: 10, right: 14, bottom: 0, left: 0 }} onClick={state => { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}>
|
||||
<CartesianGrid stroke="#e9eff7" vertical={false} /><XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} axisLine={{ stroke: '#dbe4ef' }} tickLine={false} interval={mode === 'monthly' ? 0 : 'preserveStartEnd'} minTickGap={4} />
|
||||
<YAxis allowDecimals={false} width={44} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip content={({ active, payload }) => {
|
||||
const item = payload?.[0]?.payload as HistoryPoint | undefined;
|
||||
const values = item && displayHistoryCounts(item);
|
||||
return active && item ? <div className="asset-history-tooltip"><strong>{item.label} · {HISTORY_STATE_LABELS[item.state]}</strong>{values ? <p>总数 {values.total ?? '待核实'} · 运营 {values.operating} · 库存 {values.inventory}</p> : <p>暂无数量</p>}</div> : null;
|
||||
}} />
|
||||
<Bar onClick={selectBar} dataKey="operating" name="运营数" stackId="assets" fill="#347df5" maxBarSize={32} isAnimationActive={false} />
|
||||
<Bar onClick={selectBar} dataKey="inventory" name="库存数" stackId="assets" fill="#9ed1fa" maxBarSize={32} isAnimationActive={false} />
|
||||
<Line dataKey="total" name="资产总数" stroke="#132e57" strokeWidth={2} connectNulls={false} dot={{ r: 3, fill: '#fff', strokeWidth: 2 }} activeDot={{ r: 5 }} isAnimationActive={false}><LabelList dataKey="total" position="top" offset={8} fontSize={10} fill="#132e57" /></Line>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="asset-history-legend" aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span></div>
|
||||
{point && <div className="asset-history-selected" aria-live="polite">
|
||||
<div className="asset-history-selected-heading"><strong>{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}</strong><span>{HISTORY_STATE_LABELS[point.state]}</span>{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}</div>
|
||||
<div className="asset-history-counts">{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) => <div key={key}><span aria-label={`${label}(台)`}>{label}(台)</span><strong>{displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}</strong></div>)}</div>
|
||||
{hasComparison ? <div className="asset-history-comparison"><span>{point.comparisonLabel}</span><div>{(['total', 'inventory', 'operating'] as const).map(key => {
|
||||
const difference = displayHistoryDifference(point, key);
|
||||
return <span key={key} className={difference != null && difference > 0 ? 'asset-history-increase' : difference != null && difference < 0 ? 'asset-history-decrease' : ''}>{difference != null ? signedCount(difference) : '—'}</span>;
|
||||
})}</div></div> : <p>{point.comparisonLabel}</p>}
|
||||
</div>}
|
||||
{point && mode === 'monthly' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={18} /></button>}
|
||||
<p className="asset-history-footnote">历史库存状态快照缺失,核对中,暂不展示。</p>
|
||||
</>}
|
||||
{details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useDialogFocus } from '../components/useDialogFocus';
|
||||
import { HISTORY_STATE_LABELS, historyTime, type HistoryCategory, type HistoryDetails, type HistoryPoint } from '../../../shared/assets/history';
|
||||
import { fetchHistoryDetails } from './api';
|
||||
import { displayHistoryCounts } from './presentation';
|
||||
|
||||
export function HistoryDetailsDialog({ point, subject, close }: { point: HistoryPoint; subject: string | null; close: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [category, setCategory] = useState<HistoryCategory>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<HistoryDetails | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retry, setRetry] = useState(0);
|
||||
useDialogFocus(true, ref, close);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { setQuery(search.trim()); setPage(1); }, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
setLoading(true); setError(''); setData(null);
|
||||
fetchHistoryDetails(point.month, 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]);
|
||||
const pages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
|
||||
return createPortal(<div className="asset-history-overlay" onMouseDown={e => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div className="asset-history-dialog" role="dialog" aria-modal="true" aria-labelledby="asset-history-detail-title" tabIndex={-1} ref={ref}>
|
||||
<header><div><h2 id="asset-history-detail-title">{point.month.replace('-', '年')}月资产明细</h2>
|
||||
<p>{subject || '全部公司'} · {HISTORY_STATE_LABELS[point.state]} · {point.reconstruction ? '参考时点' : point.state === 'ARCHIVED' ? '快照' : '截至'} {historyTime(point.reconstruction ? point.scheduledFor : data?.point.asOf || point.asOf)}</p></div>
|
||||
<button type="button" onClick={close} aria-label="关闭月度明细"><X size={20} /></button></header>
|
||||
<div className="asset-history-detail-tools">
|
||||
<label>车辆分类<select aria-label="月度明细分类" value={category} onChange={e => { setCategory(e.target.value as HistoryCategory); setPage(1); }}>
|
||||
<option value="all">{point.reconstruction ? '全部推算车辆' : '全部资产'}</option><option value="operating">运营车辆</option><option value="inventory">库存车辆</option></select></label>
|
||||
<label className="asset-history-search"><Search size={16} /><input aria-label="搜索历史车牌或VIN" placeholder="搜索车牌 / VIN" value={search} onChange={e => setSearch(e.target.value)} /></label>
|
||||
</div>
|
||||
<div className="asset-history-detail-body" aria-busy={loading} aria-live="polite">
|
||||
{loading ? <p className="asset-history-empty">正在加载月度明细…</p> : error ? <p className="asset-history-empty" role="alert">{error} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : data && <>
|
||||
<p className="asset-history-detail-note">{data.point.reconstruction ? '以下为核对表中的交还记录推算车辆,归属未知项留空。历史总数待核实,不能将两类相加当作历史资产总数。' : data.point.state === 'ARCHIVED' ? '以下为当时归档的车辆及归属信息。' : '以下为最新实时明细,数量可能随业务更新。'} 当前范围:总数 {displayHistoryCounts(data.point)?.total ?? '待核实'} · 运营 {displayHistoryCounts(data.point)?.operating} · 库存 {displayHistoryCounts(data.point)?.inventory}</p>
|
||||
{data.items.length ? <><div className="asset-history-detail-table"><table><thead><tr><th>车辆</th><th>分类</th><th>公司 / 部门</th><th>负责人 / 客户</th></tr></thead>
|
||||
<tbody>{data.items.map(item => <tr key={item.id}><td><strong>{item.plateNumber || '未登记车牌'}</strong><small>{item.model}</small><small>VIN:{item.vin || '未提供'}</small>{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}</td>
|
||||
<td>{item.disposition === 'operating' ? '运营' : '库存'}</td><td>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></td>
|
||||
<td>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small><small>{item.contractNo || ''}</small></td></tr>)}</tbody></table></div>
|
||||
<div className="asset-history-mobile-vehicles">{data.items.map(item => <article key={item.id}>
|
||||
<div className="asset-history-mobile-vehicle-heading"><strong>{item.plateNumber || '未登记车牌'}</strong><span>{item.disposition === 'operating' ? '运营' : '库存'}</span></div>
|
||||
<p>{item.model}</p><small>VIN:{item.vin || '未提供'}</small>
|
||||
{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}
|
||||
<dl><div><dt>公司 / 部门</dt><dd>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></dd></div>
|
||||
<div><dt>负责人 / 客户</dt><dd>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small></dd></div></dl>
|
||||
{item.contractNo && <small>合同:{item.contractNo}</small>}
|
||||
</article>)}</div></> : <p className="asset-history-empty">当前条件下没有车辆</p>}
|
||||
</>}
|
||||
</div>
|
||||
<footer><span>{data ? `${point.reconstruction ? '推算' : '筛选'}结果 ${data.total} 台 · 第 ${page} / ${pages} 页` : '每页 30 台'}</span><div>
|
||||
<button type="button" aria-label="上一页" disabled={loading || !data || page <= 1} onClick={() => setPage(x => x - 1)}><ChevronLeft size={16} /></button>
|
||||
<button type="button" aria-label="下一页" disabled={loading || !data || page >= pages} onClick={() => setPage(x => x + 1)}><ChevronRight size={16} /></button></div></footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Select } from 'radix-ui';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
interface HistorySelectProps {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string; detail?: string }[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function HistorySelect({ label, value, options, onChange }: HistorySelectProps) {
|
||||
return <Select.Root value={value} onValueChange={onChange}>
|
||||
<Select.Trigger className="asset-history-select" aria-label={label}>
|
||||
<Select.Value />
|
||||
<Select.Icon className="asset-history-select-icon"><ChevronDown size={14} /></Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="asset-history-select-menu" position="popper" align="start" sideOffset={6} collisionPadding={10}>
|
||||
<Select.ScrollUpButton className="asset-history-select-scroll"><ChevronUp size={14} /></Select.ScrollUpButton>
|
||||
<Select.Viewport className="asset-history-select-options">
|
||||
{options.map(option => <Select.Item className="asset-history-select-option" key={option.value} value={option.value} textValue={option.label}>
|
||||
<Select.ItemText>{option.label}</Select.ItemText>
|
||||
{option.detail && <span className="asset-history-option-detail">{option.detail}</span>}
|
||||
<Select.ItemIndicator className="asset-history-option-check"><Check size={14} /></Select.ItemIndicator>
|
||||
</Select.Item>)}
|
||||
</Select.Viewport>
|
||||
<Select.ScrollDownButton className="asset-history-select-scroll"><ChevronDown size={14} /></Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fetchAuthenticated } from '../../../auth/api-client';
|
||||
import type { HistoryMode, HistoryTrend, HistoryPoint, HistoryDetails, HistoryCategory } from '../../../shared/assets/history';
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string | number | null | undefined>, signal: AbortSignal): Promise<T> {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) if (value != null && value !== '') query.set(key, String(value));
|
||||
const response = await fetchAuthenticated(`/api/asset-history/${path}?${query}`, { signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error || '历史趋势加载失败,请重试');
|
||||
return body as T;
|
||||
}
|
||||
export function fetchHistoryTrend(mode: HistoryMode, year: number, subject: string | null, signal: AbortSignal) {
|
||||
return get<HistoryTrend>('trend', { mode, year, subject }, signal);
|
||||
}
|
||||
export function fetchHistoryLive(mode: HistoryMode, subject: string | null, signal: AbortSignal) {
|
||||
return get<HistoryPoint>('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<HistoryDetails>('details', { month, batchId, subject, category, search, page, pageSize: 30 }, signal);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
.asset-history { --history-blue:#1559e8; margin:8px 0 16px; padding:16px; border:1px solid #e6edf5; border-radius:20px; background:#fff; color:#142b4e; box-shadow:0 5px 24px rgb(22 53 90 / 3%); }
|
||||
.asset-history button,.asset-history-dialog button { cursor:pointer; }
|
||||
.asset-history button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; }
|
||||
.asset-history button:focus-visible,.asset-history select:focus-visible,.asset-history-dialog :is(button,input,select):focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
|
||||
.asset-history-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.asset-history-header h2 { margin:0; font-size:20px; font-weight:750; letter-spacing:-.5px; }
|
||||
.asset-history-modes { display:flex; gap:2px; background:#eef3fa; border:1px solid #e7edf6; border-radius:11px; padding:3px; }
|
||||
.asset-history-modes button { padding:7px 20px; min-height:34px; border-radius:8px; font-size:13px; font-weight:600; transition:background .15s,color .15s,box-shadow .15s; color:#64748b; }
|
||||
.asset-history-modes button[aria-pressed=true] { background:#fff; color:#1559e8; box-shadow:0 1px 4px #193c6920, inset 0 0 0 1px #dce7f5; }
|
||||
.asset-history-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0 4px; font-size:12px; color:#64748b; }
|
||||
.asset-history-toolbar>span { margin-left:auto; }
|
||||
.asset-history-toolbar .asset-history-annual-note { margin-left:0; margin-right:auto; }
|
||||
.asset-history-refresh { display:grid; place-items:center; padding:8px; border-radius:8px; color:#7c8da5; transition:background .15s,color .15s; }
|
||||
.asset-history-warning { margin:8px 0; font-size:12px; line-height:1.7; color:#74613a; background:#fff9ec; padding:9px 12px; border-radius:9px; }
|
||||
.asset-history-warning button,.asset-history-empty button { color:#1559e8; text-decoration:underline; padding:6px; }
|
||||
.asset-history-unit { font-size:12px; color:#64748b; margin:6px 0 0; }
|
||||
.asset-history-chart { height:200px; width:100%; min-width:0; }
|
||||
.asset-history-tooltip { background:#fff; border:1px solid #dce5ef; border-radius:10px; padding:10px 12px; box-shadow:0 4px 16px #142b4e15; font-size:12px; }
|
||||
.asset-history-tooltip p { margin-top:4px; }
|
||||
.asset-history-legend { display:flex; justify-content:center; flex-wrap:wrap; gap:22px; color:#64748b; font-size:12px; margin:6px 0; }
|
||||
.asset-history-legend span { display:flex; align-items:center; gap:7px; }
|
||||
.asset-history-legend i { display:inline-block; height:11px; width:11px; border-radius:2px; }
|
||||
.asset-history-legend .operating { background:#347df5; }.asset-history-legend .inventory { background:#9ed1fa; }
|
||||
.asset-history-legend .total { width:20px; height:2px; background:#132e57; }
|
||||
.asset-history-selected { margin-top:8px; display:grid; grid-template-columns:90px minmax(0,1fr); align-items:center; gap:0 10px; padding:10px 14px; background:linear-gradient(115deg,#f3f7fe,#edf5ff); border:1px solid #e7effb; border-radius:12px; }
|
||||
.asset-history-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; }
|
||||
.asset-history-selected-heading>strong { color:#1559e8; font-size:17px; font-weight:750; }
|
||||
.asset-history-selected-heading>span,.asset-history-selected-heading>time { color:#64748b; font-size:11px; }
|
||||
.asset-history-counts { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.asset-history-counts>div { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; min-width:0; border-right:1px solid #d4e2f4; }
|
||||
.asset-history-counts>div:last-child { border:0; }
|
||||
.asset-history-counts>div>span { color:#64748b; font-size:12px; }
|
||||
.asset-history-counts strong { font-size:23px; font-weight:750; line-height:1.2; font-variant-numeric:tabular-nums; }
|
||||
.asset-history-increase { color:#bf2f30; }.asset-history-decrease { color:#14864a; }
|
||||
.asset-history-selected>p { grid-column:1 / -1; color:#64748b; font-size:11px; text-align:center; padding-top:6px; }
|
||||
.asset-history-detail-button { display:flex; width:100%; align-items:center; justify-content:space-between; min-height:44px; padding:8px 12px; margin-top:8px; border:1px solid #dce8f8; border-radius:10px; background:#f7faff; color:#18385d; font-size:14px; font-weight:600; }
|
||||
.asset-history-footnote { display:flex; align-items:center; flex-wrap:wrap; gap:6px; color:#64748b; font-size:11px; margin-top:8px; }
|
||||
.asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:#64748b; }
|
||||
.asset-history-overlay { position:fixed; inset:0; z-index:120; background:#0c1e3b66; display:flex; justify-content:center; align-items:center; padding:24px; }
|
||||
.asset-history-dialog { display:flex; flex-direction:column; width:min(980px,100%); max-height:88dvh; border-radius:18px; background:white; color:#183454; box-shadow:0 24px 64px #142b4e30; overflow:hidden; outline:none; }
|
||||
.asset-history-dialog>header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:20px 22px; border-bottom:1px solid #e6edf5; }
|
||||
.asset-history-dialog h2 { font-size:18px; font-weight:700; }
|
||||
.asset-history-dialog header p { font-size:12px; line-height:1.7; margin-top:5px; color:#64748b; }
|
||||
.asset-history-dialog header button { padding:8px; flex-shrink:0; }
|
||||
.asset-history-detail-tools { display:flex; flex-wrap:wrap; gap:12px; padding:14px 22px; border-bottom:1px solid #e6edf5; }
|
||||
.asset-history-detail-tools label { display:flex; align-items:center; gap:8px; font-size:12px; color:#64748b; }
|
||||
.asset-history-detail-tools select { border:1px solid #dce5f0; border-radius:7px; padding:8px; color:#183454; background:white; }
|
||||
.asset-history-search { border:1px solid #dce5f0; border-radius:7px; padding:6px 10px; flex:1; min-width:170px; }
|
||||
.asset-history-search input { width:100%; min-width:0; padding:3px 0; outline:none; color:#183454; }
|
||||
.asset-history-detail-body { overflow:auto; flex:1; min-height:100px; }
|
||||
.asset-history-detail-note { font-size:12px; line-height:1.8; padding:12px 22px; color:#64748b; background:#f8fbff; }
|
||||
.asset-history-detail-table { overflow:auto; }
|
||||
.asset-history-detail-table table { width:100%; border-collapse:collapse; min-width:650px; font-size:12px; }
|
||||
.asset-history-detail-table th { text-align:left; background:#f7faff; color:#64748b; font-weight:500; padding:12px 20px; }
|
||||
.asset-history-detail-table td { vertical-align:top; padding:14px 20px; border-bottom:1px solid #edf2f8; max-width:280px; overflow-wrap:anywhere; }
|
||||
.asset-history-detail-table td strong { font-size:13px; font-weight:650; }.asset-history-detail-table small { display:block; font-size:11px; color:#64748b; margin-top:5px; }
|
||||
.asset-history-mobile-vehicles { display:none; }
|
||||
.asset-history-dialog footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 22px; border-top:1px solid #e6edf5; color:#64748b; font-size:12px; }
|
||||
.asset-history-dialog footer>div { display:flex; gap:8px; }.asset-history-dialog footer button { padding:9px; border:1px solid #dce5f0; border-radius:7px; }
|
||||
@media(max-width:640px) {
|
||||
.asset-history { padding:12px; border-radius:16px; }
|
||||
.asset-history-header h2 { font-size:18px; }.asset-history-modes button { padding:7px 15px; min-height:38px; }
|
||||
.asset-history-toolbar { gap:6px; }.asset-history-toolbar>span { font-size:10px; }
|
||||
.asset-history-chart { height:170px; }
|
||||
.asset-history-selected { grid-template-columns:72px minmax(0,1fr); gap:0 6px; padding:8px 10px; }.asset-history-selected-heading { gap:2px; }.asset-history-selected-heading>span,.asset-history-selected-heading>time { font-size:10px; }
|
||||
.asset-history-counts strong { font-size:21px; }.asset-history-counts>div>span { font-size:11px; }
|
||||
.asset-history-overlay { padding:10px; }.asset-history-dialog { max-height:94dvh; border-radius:14px; }
|
||||
.asset-history-dialog>header { padding:14px; }.asset-history-dialog h2 { font-size:16px; }.asset-history-detail-tools { padding:12px 14px; }
|
||||
.asset-history-detail-note { padding:10px 14px; }.asset-history-dialog :is(input,select) { font-size:16px; }.asset-history-dialog footer { padding:12px 14px; }
|
||||
.asset-history-dialog :is(header,footer) button { min-height:44px; min-width:44px; }
|
||||
.asset-history-detail-table { display:none; }
|
||||
.asset-history-mobile-vehicles { display:block; padding:0 14px; }
|
||||
.asset-history-mobile-vehicles article { padding:16px 0; border-bottom:1px solid #e6edf5; overflow-wrap:anywhere; }
|
||||
.asset-history-mobile-vehicle-heading { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.asset-history-mobile-vehicle-heading strong { font-size:14px; font-weight:700; }.asset-history-mobile-vehicle-heading span { font-size:11px; color:#1559e8; background:#edf5ff; border-radius:5px; padding:3px 8px; }
|
||||
.asset-history-mobile-vehicles p { margin-top:6px; font-size:12px; color:#475569; }.asset-history-mobile-vehicles small { display:block; font-size:11px; color:#64748b; margin-top:5px; }
|
||||
.asset-history-mobile-vehicles dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:12px 0; font-size:12px; }
|
||||
.asset-history-mobile-vehicles dt { font-size:11px; color:#64748b; margin-bottom:5px; }.asset-history-mobile-vehicles dd { margin:0; }
|
||||
}
|
||||
|
||||
@media(min-width:900px) {
|
||||
.asset-history-controls { display:flex; align-items:center; flex-wrap:wrap; gap:8px 24px; }
|
||||
.asset-history-header { flex:1; gap:16px; }
|
||||
.asset-history-toolbar { margin:0; }
|
||||
.asset-history-selected { display:grid; grid-template-columns:minmax(150px,1fr) 3fr; align-items:center; gap:0 18px; }
|
||||
.asset-history-selected-heading { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:2px 12px; margin:0; }
|
||||
.asset-history-selected-heading>strong { grid-row:span 2; }
|
||||
.asset-history-selected-heading>span,.asset-history-selected-heading>time { grid-column:2; }
|
||||
.asset-history-selected>p { grid-column:1 / -1; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-modes button,.asset-history-refresh { min-height:44px; }
|
||||
.asset-history-refresh { min-width:44px; }
|
||||
.asset-history-toolbar { gap:4px; }
|
||||
}
|
||||
|
||||
.asset-history-comparison { grid-column:1 / -1; display:grid; grid-template-columns:90px minmax(0,1fr); gap:10px; align-items:center; margin-top:6px; padding-top:6px; border-top:1px solid #d4e2f4; font-size:11px; color:#64748b; }
|
||||
.asset-history-comparison>div { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); text-align:center; font-size:13px; font-weight:600; }
|
||||
@media(min-width:900px) {
|
||||
.asset-history-comparison { grid-template-columns:minmax(150px,1fr) 3fr; gap:18px; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-comparison { grid-template-columns:72px minmax(0,1fr); gap:6px; }
|
||||
}
|
||||
|
||||
.asset-history-select { display:inline-flex; align-items:center; justify-content:space-between; gap:12px; min-width:76px; min-height:36px; padding:7px 11px; border:1px solid #dce5f1; border-radius:9px; background:#fff; color:#254365; font-size:13px; font-weight:550; box-shadow:0 1px 2px #173c6310; transition:border-color .15s,box-shadow .15s; }
|
||||
.asset-history-select[data-state=open] { border-color:#8fb4f2; box-shadow:0 0 0 3px #dbeaff80; }
|
||||
.asset-history-select-icon { color:#8b9db5; display:flex; transition:transform .15s; }
|
||||
.asset-history-select[data-state=open] .asset-history-select-icon { transform:rotate(180deg); }
|
||||
.asset-history-select-menu { z-index:160; min-width:max(132px,var(--radix-select-trigger-width)); max-height:min(320px,var(--radix-select-content-available-height)); max-width:calc(100vw - 20px); overflow:hidden; padding:4px; border:1px solid #e0e8f4; border-radius:12px; background:#fff; color:#254365; box-shadow:0 12px 32px #18365420,0 2px 6px #18365408; }
|
||||
.asset-history-select-options { padding:2px; }
|
||||
.asset-history-select-option { position:relative; display:flex; align-items:center; gap:16px; min-height:36px; padding:8px 30px 8px 10px; border-radius:7px; outline:none; font-size:13px; cursor:pointer; user-select:none; }
|
||||
.asset-history-select-option[data-state=checked] { color:#1559e8; background:#f0f6ff; font-weight:600; }
|
||||
.asset-history-select-option[data-highlighted] { background:#eaf2ff; color:#1559e8; }
|
||||
.asset-history-option-detail { margin-left:auto; color:#7b8da5; font-size:11px; font-weight:400; }
|
||||
.asset-history-option-check { position:absolute; right:9px; display:flex; color:#2563eb; }
|
||||
.asset-history-select-scroll { display:flex; align-items:center; justify-content:center; height:24px; color:#7b8da5; }
|
||||
.asset-history-detail-button>svg { padding:3px; width:24px; height:24px; border-radius:7px; background:#eaf2ff; color:#527eb7; }
|
||||
@media(hover:hover) {
|
||||
.asset-history-modes button:not([aria-pressed=true]):hover { color:#1559e8; background:#e5edf9; }
|
||||
.asset-history-select:hover { border-color:#adc5e8; }
|
||||
.asset-history-refresh:hover { color:#2563eb; background:#eef5ff; }
|
||||
.asset-history-detail-button:hover { background:#eef5ff; border-color:#bcd3f3; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-select { min-height:44px; padding:8px 10px; font-size:12px; gap:10px; }
|
||||
.asset-history-select-option { min-height:44px; }
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.asset-history-modes button,.asset-history-select,.asset-history-select-icon,.asset-history-refresh { transition:none; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation.js';
|
||||
import { makePoint } from '../../../server/routes/asset-history/model.js';
|
||||
import { testBatch } from '../../../server/routes/asset-history/fixtures.js';
|
||||
|
||||
test('selection prefers live then latest archive; missing is never a numerical zero',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');
|
||||
const archived=makePoint('2026-09',now,testBatch('2026-09'),[]);
|
||||
const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now);
|
||||
assert.equal(defaultHistoryPeriod([missing,archived,live]),'2026-10');
|
||||
assert.equal(defaultHistoryPeriod([missing,archived]),'2026-09');
|
||||
assert.equal(archived.counts!.total,0);assert.equal(missing.counts,null);
|
||||
assert.equal(signedCount(3),'+3');assert.equal(signedCount(-3),'-3');
|
||||
});
|
||||
test('estimated counts keep total unknown and live refresh cannot compare against a reconstructed cohort',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');
|
||||
const estimate={...makePoint('2026-09',now),state:'RECONSTRUCTED' as const,source:'RECONSTRUCTED' as const,
|
||||
reconstruction:{operating:3,inventory:0,unclassified:2,currentlyExited:1,sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'核对表',workbookSha256:'a'.repeat(64),difference:{operating:1,inventory:-1}}};
|
||||
assert.deepEqual(displayHistoryCounts(estimate),{total:null,operating:3,inventory:0});assert.equal(displayHistoryDifference(estimate,'total'),null);
|
||||
assert.equal(displayHistoryDifference(estimate,'inventory'),-1);assert.equal(defaultHistoryPeriod([estimate]),'2026-09');
|
||||
const live=makePoint('2026-10',now);live.counts={total:5,operating:2,inventory:3};
|
||||
assert.equal(updateLivePoint([estimate,live],live)[1].difference,null);
|
||||
});
|
||||
test('live refresh preserves January comparison with December outside visible year',()=>{
|
||||
const now=new Date('2026-01-19T00:00Z');const initial=makePoint('2026-01',now);
|
||||
initial.counts={total:10,operating:6,inventory:4};initial.difference={total:2,operating:1,inventory:1};
|
||||
const refreshed={...initial,counts:{total:12,operating:7,inventory:5},difference:null};
|
||||
const result=updateLivePoint([initial],refreshed)[0];
|
||||
assert.deepEqual(result.difference,{total:4,operating:2,inventory:2});assert.equal(result.comparisonLabel,'截至当前较上月末');
|
||||
});
|
||||
test('live refresh cannot mutate an archive, bridge a hole or overwrite a different period',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');const archive=makePoint('2026-09',now,testBatch('2026-09'),[]);
|
||||
const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now);
|
||||
assert.equal(updateLivePoint([archive],live)[0],archive);
|
||||
assert.equal(updateLivePoint([missing,live],{...live,counts:{total:9,operating:4,inventory:5}})[1].difference,null);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HistoryPoint } from '../../../shared/assets/history';
|
||||
|
||||
/** Accessible period buttons and chart clicks share one selection model. */
|
||||
export function defaultHistoryPeriod(points: HistoryPoint[]): string | null {
|
||||
return points.find(p => p.state === 'LIVE')?.period || [...points].reverse().find(p => p.state === 'ARCHIVED' || p.state === 'RECONSTRUCTED')?.period || points.at(-1)?.period || null;
|
||||
}
|
||||
export function updateLivePoint(points: HistoryPoint[], live: HistoryPoint): HistoryPoint[] {
|
||||
return points.map((point, index) => {
|
||||
if (point.state !== 'LIVE' || point.period !== live.period) return point;
|
||||
if (points[index - 1]?.state === 'RECONSTRUCTED') return { ...live, difference: null, comparisonLabel: '推算与实时或正式快照口径不同,暂无可比数据' };
|
||||
const baseline = points[index - 1]?.counts || (point.counts && point.difference ? {
|
||||
total: point.counts.total - point.difference.total,
|
||||
operating: point.counts.operating - point.difference.operating,
|
||||
inventory: point.counts.inventory - point.difference.inventory,
|
||||
} : null);
|
||||
return { ...live, difference: baseline && live.counts ? {
|
||||
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 ? '上年末' : '上月末'}` : '暂无可比数据' };
|
||||
});
|
||||
}
|
||||
export const signedCount = (value: number) => value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString();
|
||||
export function displayHistoryCounts(point: HistoryPoint) {
|
||||
return point.reconstruction ? { total: null, operating: point.reconstruction.operating, inventory: point.reconstruction.inventory } : point.counts;
|
||||
}
|
||||
export function displayHistoryDifference(point: HistoryPoint, key: 'total' | 'operating' | 'inventory'): number | null {
|
||||
return point.reconstruction ? key === 'total' ? null : point.reconstruction.difference?.[key] ?? null : point.difference?.[key] ?? null;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
|
||||
import vehiclesRouter from './routes/vehicles.js';
|
||||
import assetAssistantRouter from './routes/asset-assistant/index.js';
|
||||
import assetFaultRouter from './routes/asset-faults/index.js';
|
||||
import assetHistoryRouter from './routes/asset-history/index.js';
|
||||
|
||||
/**
|
||||
* 创建 HTTP 应用,但不监听端口、不访问数据库,也不启动后台任务。
|
||||
@@ -31,6 +32,7 @@ export function createApp(): Hono {
|
||||
app.use('/api/*', createPreviewMiddleware());
|
||||
|
||||
app.route('/api/vehicles', vehiclesRouter);
|
||||
app.route('/api/asset-history', assetHistoryRouter);
|
||||
app.route('/api/asset-faults', assetFaultRouter);
|
||||
app.route('/api/asset-assistant', assetAssistantRouter);
|
||||
app.route('/api/mileage', mileageRouter);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Vehicle } from '../types.js';
|
||||
import { isInventoryVehicle } from '../routes/vehicles/model.js';
|
||||
|
||||
/** 当前库存为公共资产;非库存车辆继续遵守本人/部门权限。历史业务使用原权限过滤。 */
|
||||
export function filterAssetVehiclesByPermission(items: Vehicle[], user: AuthUser): Vehicle[] {
|
||||
export function filterAssetVehiclesByPermission<T extends Vehicle>(items: T[], user: AuthUser): T[] {
|
||||
if (user.permissionLevel === 'full') return items;
|
||||
const authorized = new Set(filterByPermission(items, user));
|
||||
return items.filter(vehicle => isInventoryVehicle(vehicle) || authorized.has(vehicle));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||
import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
||||
import { startAssetHistoryScheduler } from './routes/asset-history/runtime.js';
|
||||
|
||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||
export function startBackgroundServices(): void {
|
||||
@@ -7,4 +8,5 @@ export function startBackgroundServices(): void {
|
||||
console.error('scheduling bootstrap error:', error);
|
||||
});
|
||||
startMileageBackgroundJobs();
|
||||
startAssetHistoryScheduler();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Synthetic fixtures imported only by tests and the explicitly isolated QA harness.
|
||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||
import { counts, freezeLive, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
|
||||
import { monthCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js';
|
||||
import type { HistoryReader } from './store.js';
|
||||
|
||||
export function testVehicle(id: string, overrides: Partial<Vehicle> = {}): Vehicle {
|
||||
return { id, plateNumber: `测试-${id}`, vin: `TESTVIN${id}`, type: '18T', model: '测试18T厢货', color: '白色',
|
||||
location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市', status: 'Operating', operationStatus: '1', vehicleStatus: '5',
|
||||
ownership: 'Self', rentCompany: '', contractNo: '测试合同', customerName: '测试客户公司', orgName: '业务公司',
|
||||
departmentName: '部门甲', subjectOrg: '公司甲', projectName: '测试项目', customerManager: '负责人甲', managerId: 'manager-a', brandLabel: '测试品牌', ...overrides };
|
||||
}
|
||||
export function testRow(id: string, operation = '1'): VehicleRow {
|
||||
return { id, 车牌号: `测试-${id}`, vin: `TESTVIN${id}`, 车辆租赁状态: '5', 车辆租赁状态Label: operation,
|
||||
主体: '公司甲', 合同归属部门: '部门甲', 经理ID: 'manager-a', 车辆型号Label: '18吨厢式货车' } as VehicleRow;
|
||||
}
|
||||
export function testBatch(month: string, vehicles: Vehicle[] = [], overrides: Partial<HistoryBatch> = {}): HistoryBatch {
|
||||
return { id: `batch-${month}`, month, status: 'COMPLETE', source: 'AUTO', scheduledFor: shanghaiTimestamp(monthCutoff(month)),
|
||||
capturedAt: shanghaiTimestamp(monthCutoff(month)), counts: counts(vehicles), ...overrides };
|
||||
}
|
||||
export class MemoryHistoryReader implements HistoryReader {
|
||||
settingsValue: HistorySettings | null = { enabledAt: '2026-08-01T00:00:00+08:00' };
|
||||
catalog: HistoryBatch[] = [];
|
||||
rows = new Map<string, HistoryVehicle[]>();
|
||||
calls: string[] = [];
|
||||
async settings() { this.calls.push('settings'); return this.settingsValue; }
|
||||
async batches() { this.calls.push('batches'); return this.catalog; }
|
||||
async vehicles(ids: string[]) { this.calls.push('vehicles'); return new Map(ids.map(id => [id, this.rows.get(id) || []])); }
|
||||
add(month: string, vehicles: Vehicle[], overrides: Partial<HistoryBatch> = {}) {
|
||||
const batch = testBatch(month, vehicles, overrides); this.catalog.push(batch); this.rows.set(batch.id, freezeLive(vehicles)); return batch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import pool from '../../db.js';
|
||||
import { vehicleRepository } from '../vehicles/repository.js';
|
||||
import { MysqlHistoryStore } from './store.js';
|
||||
import { HistoryService, HistoryInputError, HistoryPeriodUnavailable } from './service.js';
|
||||
|
||||
export const historyStore = new MysqlHistoryStore(pool);
|
||||
// Published routes expose live data and verified snapshots only.
|
||||
// The retired handover reconstruction remains an offline audit artifact.
|
||||
export const historyService = new HistoryService(historyStore, () => vehicleRepository.getVehicles());
|
||||
export function createHistoryRouter(service: Pick<HistoryService, 'trend' | 'details' | 'livePoint'> = historyService) {
|
||||
const router = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
router.use('*', async (c, next) => {
|
||||
c.header('Cache-Control', 'private, no-store');
|
||||
if (!c.get('user')) return c.json({ error: 'Unauthorized' }, 401);
|
||||
await next();
|
||||
});
|
||||
router.get('/trend', async c => c.json(await service.trend(c.get('user'), c.req.query())));
|
||||
router.get('/live', async c => c.json(await service.livePoint(c.get('user'), c.req.query())));
|
||||
router.get('/details', async c => c.json(await service.details(c.get('user'), c.req.query())));
|
||||
router.onError((error, c) => {
|
||||
if (error instanceof HistoryInputError) return c.json({ error: error.message }, 400);
|
||||
if (error instanceof HistoryPeriodUnavailable) return c.json({ error: error.message }, 409);
|
||||
console.error('[asset-history] read failed');
|
||||
return c.json({ error: '资产历史数据暂不可用,请稍后重试' }, 503);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
export default createHistoryRouter();
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { monthCutoff, nextCapture, shanghaiMonth, shanghaiTimestamp } from '../../../shared/assets/history.js';
|
||||
import { counts, freezeRows, makePoint, freezeLive } from './model.js';
|
||||
import { testBatch, testRow, testVehicle } from './fixtures.js';
|
||||
|
||||
test('Shanghai month-end cutoff covers leap years, short/long months and cross-year boundaries', () => {
|
||||
for (const [month, expected] of [['2024-02','2024-02-29'], ['2026-02','2026-02-28'], ['2026-04','2026-04-30'], ['2026-10','2026-10-31'], ['2026-12','2026-12-31']]) {
|
||||
assert.equal(shanghaiTimestamp(monthCutoff(month)), `${expected}T17:00:00.000+08:00`);
|
||||
assert.equal(monthCutoff(month).toISOString(), `${expected}T09:00:00.000Z`);
|
||||
}
|
||||
assert.equal(shanghaiMonth(new Date('2026-12-31T16:00:00Z')), '2027-01');
|
||||
assert.equal(nextCapture(new Date('2026-12-31T09:00:00Z')), '2027-01-31T17:00:00.000+08:00');
|
||||
assert.throws(() => monthCutoff('2026-13'));
|
||||
});
|
||||
test('host TZ cannot change cutoff or Shanghai calendar', () => {
|
||||
const script = "import {monthCutoff,shanghaiMonth} from './src/shared/assets/history.ts'; console.log(monthCutoff('2024-02').toISOString(),shanghaiMonth(new Date('2026-12-31T16:01:00Z')))";
|
||||
const run = (TZ: string) => execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', script], { env: { ...process.env, TZ }, encoding: 'utf8' }).trim();
|
||||
assert.equal(run('UTC'), run('America/Los_Angeles'));
|
||||
});
|
||||
test('source rows preserve precise IDs and raw states; pending and abnormal do not add to inventory', () => {
|
||||
const rows = [testRow('9223372036854775000'), testRow('self','2'), { ...testRow('pending','3'), 车辆租赁状态: '4' }, { ...testRow('abnormal','4'), 车辆租赁状态: '14' }];
|
||||
const frozen = freezeRows(rows);
|
||||
assert.deepEqual(counts(frozen), { total:4, operating:2, inventory:2 });
|
||||
assert.equal(frozen[0].id, '9223372036854775000');
|
||||
assert.equal(frozen[2].vehicleStatus, '4');
|
||||
assert.equal(frozen[3].vehicleStatus, '14');
|
||||
assert.equal('abnormalMove' in frozen[0], false);
|
||||
assert.throws(() => freezeRows([testRow('1'),testRow('1')]), /重复/);
|
||||
for (const state of ['5','99','']) assert.throws(() => freezeRows([testRow('bad',state)]), /未分类/);
|
||||
});
|
||||
test('cutoff switches live to capturing/missing without fallback; a zero snapshot is archived', () => {
|
||||
const live = freezeLive([testVehicle('1')]);
|
||||
assert.equal(makePoint('2026-10', new Date('2026-10-31T08:59:59Z'), undefined, [], live).state, 'LIVE');
|
||||
assert.equal(makePoint('2026-10', new Date('2026-10-31T09:00:00Z'), undefined, [], live).state, 'CAPTURING');
|
||||
const missing = makePoint('2026-10', new Date('2026-10-31T09:01:00Z'), undefined, [], live);
|
||||
assert.equal(missing.state, 'MISSING'); assert.equal(missing.counts, null);
|
||||
const archived = makePoint('2026-10', new Date('2026-10-31T09:00:20Z'), testBatch('2026-10'), [], live);
|
||||
assert.equal(archived.state, 'ARCHIVED'); assert.deepEqual(archived.counts, { total:0,operating:0,inventory:0 });
|
||||
assert.equal(makePoint('2026-11', new Date('2026-10-19T00:00Z')).state, 'FUTURE');
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { filterAssetVehiclesByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||
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';
|
||||
|
||||
export interface HistoryVehicle extends Vehicle {
|
||||
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;
|
||||
counts: HistoryCounts;
|
||||
}
|
||||
export interface HistorySettings { enabledAt: string }
|
||||
export class HistoryDataError extends Error {}
|
||||
export class HistoryStorageUnavailable extends Error {}
|
||||
|
||||
export function validateVehicles(vehicles: Vehicle[]): void {
|
||||
const ids = new Set<string>();
|
||||
for (const vehicle of vehicles) {
|
||||
if (typeof vehicle.id !== 'string' || !vehicle.id || ids.has(vehicle.id)) throw new HistoryDataError('车辆 ID 缺失或重复,无法生成可信快照');
|
||||
ids.add(vehicle.id);
|
||||
if (!isOperatingVehicle(vehicle) && !isInventoryVehicle(vehicle)) throw new HistoryDataError('存在未分类运营状态,无法生成可信快照');
|
||||
}
|
||||
}
|
||||
export function freezeRows(rows: VehicleRow[]): HistoryVehicle[] {
|
||||
const vehicles = rows.map(row => {
|
||||
const vehicle = transformRow(row);
|
||||
return { ...vehicle, vehicleStatus: row.车辆租赁状态,
|
||||
disposition: isOperatingVehicle(vehicle) ? 'operating' as const : 'inventory' as const };
|
||||
});
|
||||
validateVehicles(vehicles);
|
||||
// Only freeze fields used by asset history. Movement work/customer attachments are unnecessary.
|
||||
return vehicles.map(({ abnormalMove: _movement, pendingDelivery: _pending, ...vehicle }) => vehicle);
|
||||
}
|
||||
export function freezeLive(vehicles: Vehicle[]): HistoryVehicle[] {
|
||||
validateVehicles(vehicles);
|
||||
return vehicles.map(({ abnormalMove: _movement, pendingDelivery: _pending, ...v }) => ({ ...v,
|
||||
vehicleStatus: v.vehicleStatus || null, disposition: isOperatingVehicle(v) ? 'operating' : 'inventory' }));
|
||||
}
|
||||
export function authorizedVehicles(vehicles: HistoryVehicle[], user: AuthUser, subject?: string): HistoryVehicle[] {
|
||||
return maskCustomerNames(filterAssetVehiclesByPermission(vehicles, user).filter(v => !subject || v.subjectOrg === subject));
|
||||
}
|
||||
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 = monthCutoff(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';
|
||||
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';
|
||||
const available = state === 'ARCHIVED' || state === 'LIVE';
|
||||
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,
|
||||
scheduledFor: shanghaiTimestamp(cutoff),
|
||||
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? 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,
|
||||
};
|
||||
}
|
||||
export function comparePoint(point: HistoryPoint, previous: HistoryPoint | undefined, annual: boolean): HistoryPoint {
|
||||
if (point.reconstruction || previous?.reconstruction) {
|
||||
const comparable = point.reconstruction && previous?.reconstruction && point.reconstruction.workbookSha256 === previous.reconstruction.workbookSha256;
|
||||
return { ...point, difference: null,
|
||||
...(point.reconstruction ? { reconstruction: { ...point.reconstruction, difference: comparable ? {
|
||||
operating: point.reconstruction.operating - previous!.reconstruction!.operating,
|
||||
inventory: point.reconstruction.inventory - previous!.reconstruction!.inventory,
|
||||
} : null } } : {}),
|
||||
comparisonLabel: comparable ? `推算值较${annual ? '上年年末' : '上月末'}` : '推算与实时或正式快照口径不同,暂无可比数据' };
|
||||
}
|
||||
const label = annual ? '上年末' : '上月末';
|
||||
return { ...point, difference: point.counts && previous?.counts ? {
|
||||
total: point.counts.total - previous.counts.total,
|
||||
inventory: point.counts.inventory - previous.counts.inventory,
|
||||
operating: point.counts.operating - previous.counts.operating,
|
||||
} : null, comparisonLabel: point.counts && previous?.counts ? `${point.state === 'LIVE' ? '截至当前' : ''}较${label}` : '暂无可比数据' };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { MemoryHistoryReader, testVehicle } from './fixtures.js';
|
||||
import { HistoryService, HistoryPeriodUnavailable } from './service.js';
|
||||
import { FileReconstructionReader, publishReconstruction, reconstructionPoint, validateReconstruction, type ReconstructionData } from './reconstruction.js';
|
||||
|
||||
const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'1',userName:'测试',loginName:'test',depName:'甲',depCode:'a',permissionLevel,roles:[] });
|
||||
const now = () => new Date('2026-10-19T14:38:33+08:00');
|
||||
const data = (): ReconstructionData => ({ schemaVersion:1,method:'HANDOVER_REVIEW_V1',workbookSha256:'a'.repeat(64),sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'测试核对表.xlsx',
|
||||
periods:[{month:'2026-08',operating:1,inventory:0,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'operating',basis:'交车 D:1,2026-08-01 10:00:00'}]},
|
||||
{month:'2026-09',operating:0,inventory:1,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'inventory',basis:'还车 R:2,2026-09-01 10:00:00'}]}] });
|
||||
function setup(value = data()) {
|
||||
const store = new MemoryHistoryReader(); let reads = 0;
|
||||
const service = new HistoryService(store,async()=>[testVehicle('today')],now,{read:async()=>{reads++;return value;}});
|
||||
return { store,service,reads:()=>reads };
|
||||
}
|
||||
test('reconstruction preserves unknown total, source date and string IDs without claiming a captured snapshot', async()=>{
|
||||
const {service}=setup(); const trend=await service.trend(user(),{year:'2026'});
|
||||
const p=trend.points[8];
|
||||
assert.equal(p.state,'RECONSTRUCTED');assert.equal(p.counts,null);assert.equal(p.asOf,null);
|
||||
assert.equal(p.reconstruction!.operating,0);assert.equal(p.reconstruction!.inventory,1);
|
||||
assert.deepEqual(p.reconstruction!.difference,{operating:-1,inventory:1});
|
||||
assert.equal(trend.points[7].reconstruction!.difference,null);
|
||||
assert.equal(trend.points[9].state,'LIVE');assert.equal(trend.points[9].difference,null);
|
||||
assert.equal(trend.points[10].state,'FUTURE');
|
||||
const detail=await service.details(user(),{month:'2026-09',batchId:p.batchId!});
|
||||
assert.equal(detail.counts,null);assert.equal(detail.items[0].id,'90071992547409931');
|
||||
assert.equal(detail.items[0].plateNumber,'历史车牌');assert.equal(detail.items[0].subjectOrg,null);
|
||||
assert.equal(detail.items[0].operationStatus,null);assert.equal(detail.items[0].customerName,null);
|
||||
assert.match(detail.items[0].reconstructionBasis!,/R:2/);
|
||||
});
|
||||
test('company/department/personal scopes cannot load or query unowned reconstruction rows', async()=>{
|
||||
for(const [level,params] of [['department',{}],['personal',{}],['full',{subject:'任意公司'}]] as const){
|
||||
const {service,reads}=setup();const identity=user(level);
|
||||
const trend=await service.trend(identity,{year:'2026',...params});assert.equal(trend.points[8].state,'MISSING');
|
||||
await assert.rejects(()=>service.details(identity,{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`,...params}),HistoryPeriodUnavailable);
|
||||
assert.equal(reads(),0);
|
||||
}
|
||||
});
|
||||
test('formal archives take precedence and forged or stale reconstruction batch IDs are refused',async()=>{
|
||||
const {service,store}=setup();store.add('2026-09',[testVehicle('formal')]);
|
||||
const trend=await service.trend(user(),{});assert.equal(trend.points[8].state,'ARCHIVED');assert.equal(trend.points[8].reconstruction,undefined);
|
||||
await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`}),HistoryPeriodUnavailable);
|
||||
await assert.rejects(()=>service.details(user(),{month:'2026-08',batchId:'rec-forged'}),HistoryPeriodUnavailable);
|
||||
assert.equal((await service.details(user(),{month:'2026-09'})).items[0].id,'formal');
|
||||
});
|
||||
test('reconstructed category/search/pagination counts agree with the selected period; missing months stay absent',async()=>{
|
||||
const value=data();value.periods[1].vehicles=Array.from({length:61},(_,i)=>({id:String(i+1),plateNumber:`历史${i}`,vin:`OLD${i}`,disposition:'inventory',basis:'还车 R:1'}));value.periods[1].inventory=61;
|
||||
const {service}=setup(value);
|
||||
const all=await service.details(user(),{month:'2026-09',page:'2',pageSize:'30'});assert.equal(all.total,61);assert.equal(all.items.length,30);
|
||||
assert.equal((await service.details(user(),{month:'2026-09',category:'operating'})).total,0);
|
||||
assert.equal((await service.details(user(),{month:'2026-09',search:'OLD60'})).total,1);
|
||||
assert.equal((await service.trend(user(),{})).points[6].state,'MISSING');
|
||||
});
|
||||
test('annual estimates reuse December only; no sum, current-month replacement, or cross-source difference',async()=>{
|
||||
const value=data();value.periods.push({...value.periods[0],month:'2025-12'});
|
||||
const {service}=setup(value);const result=await service.trend(user(),{mode:'annual'});
|
||||
assert.equal(result.points.find(p=>p.period==='2025')!.reconstruction!.operating,1);
|
||||
assert.equal(result.points.find(p=>p.period==='2026')!.state,'LIVE');assert.equal(result.points.at(-1)!.difference,null);
|
||||
assert.equal(reconstructionPoint(value,'2026-10',false,now()),null);
|
||||
});
|
||||
test('schema rejects duplicates, unsafe IDs, invented counts, current/future months and missing provenance',()=>{
|
||||
const mutate=[(d:ReconstructionData)=>{d.periods.push(d.periods[0]);},(d:ReconstructionData)=>{d.periods[0].vehicles[0].id=1 as never;},
|
||||
(d:ReconstructionData)=>{d.periods[0].operating=99;},(d:ReconstructionData)=>{d.periods[0].month='2026-10';},
|
||||
(d:ReconstructionData)=>{d.workbookSha256='';},(d:ReconstructionData)=>{d.periods[0].vehicles.push(d.periods[0].vehicles[0]);d.periods[0].operating=2;},
|
||||
(d:ReconstructionData)=>{d.periods[0].unclassified=-1;}];
|
||||
for(const change of mutate){const d=data();change(d);assert.throws(()=>validateReconstruction(d),/无效/);}
|
||||
const d=data();Object.assign(d.periods[0].vehicles[0],{subjectOrg:'今日主体',customerName:'不应导入的客户'});
|
||||
assert.equal(JSON.stringify(validateReconstruction(d)).includes('今日主体'),false);
|
||||
const zero=data();zero.periods[0]={month:'2026-08',operating:0,inventory:0,unclassified:3,currentlyExited:0,vehicles:[]};
|
||||
assert.equal(reconstructionPoint(validateReconstruction(zero),'2026-08',false,now())!.reconstruction!.operating,0);
|
||||
});
|
||||
test('explicit local publication is atomic, idempotent and immutable; reader has no database dependencies',async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'asset-reconstruction-'));
|
||||
try{
|
||||
const path=join(dir,'history.json');const value=data();
|
||||
const results=await Promise.all([publishReconstruction(path,value),publishReconstruction(path,value)]);
|
||||
assert.deepEqual(results.sort(),['created','unchanged']);
|
||||
const before=await readFile(path,'utf8');const different=data();different.workbookSha256='b'.repeat(64);
|
||||
await assert.rejects(()=>publishReconstruction(path,different),/禁止自动覆盖/);assert.equal(await readFile(path,'utf8'),before);
|
||||
assert.deepEqual(await new FileReconstructionReader(path).read(),validateReconstruction(value));
|
||||
}finally{await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFile, writeFile, link, unlink, mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { monthCutoff, shanghaiTimestamp, type HistoryDetailItem, type HistoryPoint } from '../../../shared/assets/history.js';
|
||||
|
||||
export interface ReconstructionPeriod {
|
||||
month: string; operating: number; inventory: number; unclassified: number; currentlyExited: number;
|
||||
vehicles: { id: string; plateNumber: string; vin: string; disposition: 'operating' | 'inventory'; basis: string }[];
|
||||
}
|
||||
export interface ReconstructionData {
|
||||
schemaVersion: 1; method: 'HANDOVER_REVIEW_V1'; workbookSha256: string;
|
||||
sourceAsOf: string; sourceReference: string; periods: ReconstructionPeriod[];
|
||||
}
|
||||
export function validateReconstruction(input: unknown): ReconstructionData {
|
||||
const fail = (): never => { throw new Error('历史回溯文件无效,未加载任何回填数据'); };
|
||||
if (!input || typeof input !== 'object') return fail();
|
||||
const data = input as ReconstructionData;
|
||||
if (data.schemaVersion !== 1 || data.method !== 'HANDOVER_REVIEW_V1' || !/^[a-f0-9]{64}$/.test(data.workbookSha256) ||
|
||||
typeof data.sourceReference !== 'string' || !data.sourceReference.trim() || data.sourceReference.length > 200 ||
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?\+08:00$/.test(data.sourceAsOf) || !Number.isFinite(Date.parse(data.sourceAsOf)) ||
|
||||
!Array.isArray(data.periods) || data.periods.length > 1200) return fail();
|
||||
const months = new Set<string>();
|
||||
for (const period of data.periods) {
|
||||
if (!period || !/^20\d{2}-(0[1-9]|1[0-2])$/.test(period.month) || months.has(period.month) || monthCutoff(period.month).getTime() >= Date.parse(data.sourceAsOf) ||
|
||||
![period.operating, period.inventory, period.unclassified, period.currentlyExited].every(v => Number.isSafeInteger(v) && v >= 0) ||
|
||||
period.currentlyExited > period.operating + period.inventory || !Array.isArray(period.vehicles) || period.vehicles.length !== period.operating + period.inventory) return fail();
|
||||
months.add(period.month);
|
||||
const ids = new Set<string>(); let operating = 0;
|
||||
for (const v of period.vehicles) {
|
||||
if (!v || typeof v.id !== 'string' || !/^[1-9]\d{0,63}$/.test(v.id) || ids.has(v.id) || !['operating', 'inventory'].includes(v.disposition) ||
|
||||
typeof v.plateNumber !== 'string' || v.plateNumber.length > 100 || typeof v.vin !== 'string' || v.vin.length > 100 ||
|
||||
typeof v.basis !== 'string' || !v.basis.trim() || v.basis.length > 1000) return fail();
|
||||
ids.add(v.id); if (v.disposition === 'operating') operating++;
|
||||
}
|
||||
if (operating !== period.operating) return fail();
|
||||
}
|
||||
// Return only reviewed fields. Ignore any attempted current company/customer/role injection.
|
||||
return { schemaVersion: 1, method: data.method, workbookSha256: data.workbookSha256, sourceAsOf: data.sourceAsOf, sourceReference: data.sourceReference,
|
||||
periods: data.periods.map(p => ({ month: p.month, operating: p.operating, inventory: p.inventory, unclassified: p.unclassified, currentlyExited: p.currentlyExited,
|
||||
vehicles: p.vehicles.map(v => ({ id: v.id, plateNumber: v.plateNumber, vin: v.vin, disposition: v.disposition, basis: v.basis })) })) };
|
||||
}
|
||||
|
||||
export interface ReconstructionReader { read(): Promise<ReconstructionData> }
|
||||
/** Opt-in, immutable local file. Reading never creates storage or writes a database. */
|
||||
export class FileReconstructionReader implements ReconstructionReader {
|
||||
private loaded?: Promise<ReconstructionData>;
|
||||
constructor(private readonly path: string) {}
|
||||
read() {
|
||||
this.loaded ||= readFile(this.path, 'utf8').then(text => {
|
||||
if (text.length > 50_000_000) throw new Error('历史回溯文件超出限制');
|
||||
return validateReconstruction(JSON.parse(text));
|
||||
});
|
||||
return this.loaded;
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit local publication only: create atomically; never replace a different revision. */
|
||||
export async function publishReconstruction(path: string, input: unknown): Promise<'created' | 'unchanged'> {
|
||||
const text = JSON.stringify(validateReconstruction(input));
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporary, text, { flag: 'wx', mode: 0o600 });
|
||||
try {
|
||||
try { await link(temporary, path); return 'created'; }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
if (await readFile(path, 'utf8') === text) return 'unchanged';
|
||||
throw new Error('目标已有不同版本的回溯数据,禁止自动覆盖');
|
||||
}
|
||||
} finally { await unlink(temporary); }
|
||||
}
|
||||
|
||||
export function reconstructionPoint(data: ReconstructionData, month: string, annual: boolean, now: Date): HistoryPoint | null {
|
||||
const period = data.periods.find(p => p.month === month);
|
||||
if (!period || monthCutoff(month) >= now) return null;
|
||||
return { period: annual ? month.slice(0, 4) : month, label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month,
|
||||
scheduledFor: shanghaiTimestamp(monthCutoff(month)), asOf: null, state: 'RECONSTRUCTED', source: 'RECONSTRUCTED',
|
||||
batchId: `rec-${data.workbookSha256.slice(0, 12)}-${month}`, counts: null, difference: null, comparisonLabel: '暂无可比数据', detailAvailable: true,
|
||||
reconstruction: { operating: period.operating, inventory: period.inventory, unclassified: period.unclassified,
|
||||
currentlyExited: period.currentlyExited, sourceAsOf: data.sourceAsOf, sourceReference: data.sourceReference, workbookSha256: data.workbookSha256, difference: null } };
|
||||
}
|
||||
export function reconstructionItems(period: ReconstructionPeriod): HistoryDetailItem[] {
|
||||
return period.vehicles.map(v => ({ id: v.id, plateNumber: v.plateNumber, vin: v.vin, disposition: v.disposition,
|
||||
type: '', model: '', operationStatus: null, vehicleStatus: null, subjectOrg: null, departmentName: null,
|
||||
customerManager: null, customerName: null, contractNo: null, reconstructionBasis: v.basis }));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { historyStore } from './index.js';
|
||||
import { HistoryScheduler } from './scheduler.js';
|
||||
|
||||
let scheduler: HistoryScheduler | undefined;
|
||||
export function startAssetHistoryScheduler(): void {
|
||||
if (process.env.ASSET_HISTORY_CAPTURE_ENABLED !== '1' || scheduler) return;
|
||||
scheduler = new HistoryScheduler(historyStore);
|
||||
scheduler.start();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { HistoryScheduler, type HistoryCaptureStore } from './scheduler.js';
|
||||
import { testBatch } from './fixtures.js';
|
||||
function fixture(enabledAt='2026-10-19T13:00:00+08:00') {
|
||||
const captures:string[]=[],missed:string[]=[],failed:string[]=[];
|
||||
let activates=0;
|
||||
const store:HistoryCaptureStore={
|
||||
activate:async()=>{activates++;return{enabledAt};},batches:async()=>[],
|
||||
capture:async(month)=>{captures.push(month);return'captured';},
|
||||
recordOutcome:async(month,status)=>{(status==='FAILED'?failed:missed).push(month);},
|
||||
};
|
||||
return {store,captures,missed,failed,activates:()=>activates};
|
||||
}
|
||||
test('scheduler captures only in month-end 17:00 minute and activation is persisted once',async()=>{
|
||||
const f=fixture();let date=new Date('2026-10-31T16:59:59+08:00');
|
||||
const scheduler=new HistoryScheduler(f.store,()=>date,()=>{});
|
||||
await scheduler.tick();assert.deepEqual(f.captures,[]);
|
||||
date=new Date('2026-10-31T17:00:00+08:00');await scheduler.tick();assert.deepEqual(f.captures,['2026-10']);assert.equal(f.activates(),1);
|
||||
});
|
||||
test('restart records missed eligible months without backfilling old live data or pre-activation periods',async()=>{
|
||||
const f=fixture();const scheduler=new HistoryScheduler(f.store,()=>new Date('2027-01-15T08:00:00+08:00'),()=>{});
|
||||
await scheduler.tick();assert.deepEqual(f.captures,[]);assert.deepEqual(f.missed,['2026-10','2026-11','2026-12']);
|
||||
const late=fixture('2026-10-31T17:00:10+08:00');await new HistoryScheduler(late.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{}).tick();
|
||||
assert.deepEqual(late.captures,[]);assert.deepEqual(late.missed,[]);
|
||||
});
|
||||
test('completed December skips duplicate annual capture; failures stay marked without late retries',async()=>{
|
||||
const f=fixture();f.store.batches=async()=>[testBatch('2026-10')];
|
||||
f.store.capture=async()=>{throw new Error('source unavailable');};
|
||||
await new HistoryScheduler(f.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick();
|
||||
assert.deepEqual(f.missed,['2026-11']);assert.deepEqual(f.failed,['2026-12']);
|
||||
const done=fixture('2026-12-01T00:00+08:00');done.store.batches=async()=>[testBatch('2026-12')];
|
||||
await new HistoryScheduler(done.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick();assert.deepEqual(done.captures,[]);
|
||||
});
|
||||
test('concurrent ticks and repeated start do not register duplicate jobs',async()=>{
|
||||
const f=fixture();let release!:()=>void;
|
||||
f.store.capture=async()=>{f.captures.push('2026-10');await new Promise<void>(r=>{release=r;});return'captured';};
|
||||
const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{});
|
||||
const stop=scheduler.start();scheduler.start();
|
||||
await new Promise<void>(r=>setImmediate(r));await scheduler.tick();assert.equal(f.captures.length,1);
|
||||
stop();release();await new Promise<void>(r=>setImmediate(r));
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiMonth, shiftMonth } from '../../../shared/assets/history.js';
|
||||
import type { CaptureResult } from './store.js';
|
||||
import { HistoryDataError, type HistoryBatch, type HistorySettings } from './model.js';
|
||||
|
||||
export interface HistoryCaptureStore {
|
||||
activate(now: Date): Promise<HistorySettings>;
|
||||
batches(): Promise<HistoryBatch[]>;
|
||||
capture(month: string, clock: () => Date): Promise<CaptureResult>;
|
||||
recordOutcome(month: string, status: 'MISSED' | 'FAILED', code: string): Promise<void>;
|
||||
}
|
||||
export class HistoryScheduler {
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private running = false;
|
||||
private ticking = false;
|
||||
private settings: HistorySettings | null = null;
|
||||
constructor(private readonly store: HistoryCaptureStore, private readonly clock: () => Date = () => new Date(),
|
||||
private readonly log: (message: string) => void = console.log) {}
|
||||
|
||||
/** Exported for deterministic testing and explicit operational checks; never mounted as HTTP write. */
|
||||
async tick(): Promise<void> {
|
||||
if (this.ticking) return;
|
||||
this.ticking = true;
|
||||
try {
|
||||
const now = this.clock();
|
||||
this.settings ||= await this.store.activate(now);
|
||||
const enabled = new Date(this.settings.enabledAt);
|
||||
const existing = new Map((await this.store.batches()).map(b => [b.month, b]));
|
||||
for (let month = shanghaiMonth(enabled); month <= shanghaiMonth(now); month = shiftMonth(month, 1)) {
|
||||
const cutoff = monthCutoff(month);
|
||||
if (cutoff < enabled || cutoff > now || existing.get(month)?.status === 'COMPLETE') continue;
|
||||
if (now.getTime() >= cutoff.getTime() + CAPTURE_WINDOW_MS) {
|
||||
if (!existing.has(month)) { await this.store.recordOutcome(month, 'MISSED', 'CAPTURE_WINDOW_MISSED'); this.log(`[asset-history] missed ${month}`); }
|
||||
continue;
|
||||
}
|
||||
try { const result = await this.store.capture(month, this.clock); this.log(`[asset-history] ${month}: ${result}`); }
|
||||
catch (error) {
|
||||
const databaseCode = (error as { code?: unknown })?.code;
|
||||
const code = error instanceof HistoryDataError ? 'INVALID_VEHICLE_DATA' : typeof databaseCode === 'string' && /^[A-Z0-9_]{1,48}$/.test(databaseCode) ? databaseCode : 'CAPTURE_FAILED';
|
||||
await this.store.recordOutcome(month, 'FAILED', code);
|
||||
this.log(`[asset-history] failed ${month} (${code}); no later live data will be substituted`);
|
||||
}
|
||||
}
|
||||
} finally { this.ticking = false; }
|
||||
}
|
||||
start(): () => void {
|
||||
if (this.running) return () => this.stop();
|
||||
this.running = true;
|
||||
const run = async () => {
|
||||
if (!this.running) return;
|
||||
try { await this.tick(); } catch { this.log('[asset-history] scheduler unavailable; check migration and database access'); }
|
||||
if (!this.running) return;
|
||||
const now = this.clock();
|
||||
const untilCutoff = monthCutoff(shanghaiMonth(now)).getTime() - now.getTime();
|
||||
this.timer = setTimeout(run, untilCutoff > 0 ? Math.min(untilCutoff, 60_000) : 15_000);
|
||||
this.timer.unref?.();
|
||||
};
|
||||
void run();
|
||||
return () => this.stop();
|
||||
}
|
||||
stop(): void { this.running = false; clearTimeout(this.timer); }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { createPreviewMiddleware } from '../../auth/preview/routes.js';
|
||||
import { createHistoryRouter } from './index.js';
|
||||
import { HistoryService, HistoryInputError, HistoryPeriodUnavailable } from './service.js';
|
||||
import { HistoryStorageUnavailable } from './model.js';
|
||||
import { MemoryHistoryReader, testVehicle } from './fixtures.js';
|
||||
|
||||
const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'manager-a',loginName:'a',userName:'甲',depCode:'a',depName:'部门甲',permissionLevel,roles:[] });
|
||||
const now = () => new Date('2026-10-19T13:00:00+08:00');
|
||||
const inventory = testVehicle('stock',{ operationStatus:'3',status:'Abnormal',departmentName:'部门乙',managerId:'manager-b',subjectOrg:'历史公司' });
|
||||
const leased = testVehicle('own');
|
||||
const foreign = testVehicle('foreign',{ managerId:'manager-b',departmentName:'部门乙' });
|
||||
function setup() {
|
||||
const store = new MemoryHistoryReader(); store.add('2026-09',[leased,foreign,inventory]);
|
||||
const live = [testVehicle('today'), testVehicle('today-stock',{ operationStatus:'4',status:'Pending' })];
|
||||
return { store, live, service:new HistoryService(store,async()=>live,now) };
|
||||
}
|
||||
test('monthly holes/future remain null; current values are real; comparisons never skip missing periods', async () => {
|
||||
const {store,service}=setup(); store.add('2026-07',[leased]);
|
||||
const result=await service.trend(user(),{year:'2026'});
|
||||
assert.equal(result.points.length,12);
|
||||
assert.deepEqual(result.points[8].counts,{total:3,operating:2,inventory:1});
|
||||
assert.equal(result.points[8].difference,null); // August is missing, July cannot substitute.
|
||||
assert.deepEqual(result.points[9].difference,{total:-1,operating:-1,inventory:0});
|
||||
assert.equal(result.points[9].state,'LIVE'); assert.equal(result.points[0].counts,null);
|
||||
assert.equal(result.points[10].state,'FUTURE'); assert.equal(result.points[10].counts,null);
|
||||
assert.ok(result.subjects.includes('历史公司'));
|
||||
});
|
||||
test('annual counts use only December snapshots, not a sum of months', async () => {
|
||||
const {store,service}=setup(); store.add('2025-12',[leased]); store.add('2025-11',[leased,foreign,inventory]);
|
||||
const result=await service.trend(user(),{mode:'annual'});
|
||||
assert.deepEqual(result.points.find(p=>p.period==='2025')!.counts,{total:1,operating:1,inventory:0});
|
||||
assert.equal(result.points.at(-1)!.state,'LIVE');
|
||||
assert.deepEqual(result.points.at(-1)!.difference,{total:1,operating:0,inventory:1});
|
||||
});
|
||||
test('full/department/personal and manager aliases share snapshot/live public inventory policy', async () => {
|
||||
const {service}=setup();
|
||||
for (const level of ['full','department','personal'] as const) {
|
||||
const result=await service.trend(user(level),{});
|
||||
const september=result.points[8];
|
||||
assert.equal(september.counts!.total,level==='full'?3:2);
|
||||
const detail=await service.details(user(level),{month:'2026-09'});
|
||||
assert.equal(detail.total,september.counts!.total); assert.deepEqual(detail.counts,september.counts);
|
||||
assert.ok(detail.items.some(v=>v.id==='stock'));
|
||||
assert.equal(detail.items.some(v=>v.id==='foreign'),level==='full');
|
||||
assert.ok(!JSON.stringify(detail).includes('测试客户公司'));
|
||||
}
|
||||
const alias={...user('personal'),userId:'new-id',managerIds:['manager-a']};
|
||||
assert.equal((await service.details(alias,{month:'2026-09'})).total,2);
|
||||
});
|
||||
test('company filter intersects permission; foreign batch IDs cannot bypass month or identity', async () => {
|
||||
const {service}=setup();
|
||||
const trend=await service.trend(user('personal'),{subject:'历史公司'});
|
||||
assert.deepEqual(trend.points[8].counts,{total:1,operating:0,inventory:1});
|
||||
assert.equal((await service.details(user('personal'),{month:'2026-09',subject:'公司甲'})).total,1);
|
||||
assert.equal((await service.details(user('personal'),{month:'2026-09',subject:'任意公司'})).total,0);
|
||||
await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:'other-batch'}),HistoryPeriodUnavailable);
|
||||
});
|
||||
test('historical vehicles survive current deletion, reassignment and exit without any live join', async () => {
|
||||
const {store}=setup(); let calls=0;
|
||||
const service=new HistoryService(store,async()=>{calls++;throw new Error('live must not be read');},now);
|
||||
const result=await service.details(user('personal'),{month:'2026-09'});
|
||||
assert.deepEqual(result.items.map(v=>v.id).sort(),['own','stock']);
|
||||
assert.equal(calls,0);
|
||||
assert.equal((await service.trend(user(),{year:'2025'})).points.every(p=>p.state==='MISSING'),true);
|
||||
assert.equal(calls,0);
|
||||
});
|
||||
test('pagination/search/category use authorized snapshot only, keeping unsearched counts intact', async () => {
|
||||
const {store,service}=setup();store.add('2026-08',Array.from({length:65},(_,i)=>testVehicle(String(i))));
|
||||
const detail=await service.details(user(),{month:'2026-08',page:'2',pageSize:'30'});
|
||||
assert.equal(detail.total,65);assert.equal(detail.items.length,30);assert.equal(detail.counts!.total,65);
|
||||
const filtered=await service.details(user(),{month:'2026-09',category:'inventory',search:'TESTVINstock'});
|
||||
assert.equal(filtered.total,1);assert.equal(filtered.counts!.total,3);
|
||||
});
|
||||
test('missing storage degrades to live without any DDL; genuine database errors are not hidden', async () => {
|
||||
const {store,live}=setup();store.settings=async()=>{throw new HistoryStorageUnavailable();};store.batches=async()=>{throw new HistoryStorageUnavailable();};
|
||||
const service=new HistoryService(store,async()=>live,now);
|
||||
const result=await service.trend(user(),{});assert.equal(result.storageReady,false);assert.equal(result.points[9].counts!.total,2);assert.equal(result.points[8].counts,null);
|
||||
await assert.rejects(()=>service.details(user(),{month:'2026-09'}),HistoryPeriodUnavailable);
|
||||
store.batches=async()=>{throw new Error('database offline');};await assert.rejects(()=>service.trend(user(),{}),/offline/);
|
||||
});
|
||||
test('once cutoff passes live values cannot replace missing history, even when capture was not enabled', async () => {
|
||||
const {store}=setup();store.settingsValue=null;let liveCalls=0;
|
||||
const service=new HistoryService(store,async()=>{liveCalls++;return[leased];},()=>new Date('2026-10-31T17:00:30+08:00'));
|
||||
const result=await service.trend(user(),{});assert.equal(result.points[9].state,'MISSING');assert.equal(result.points[9].counts,null);
|
||||
assert.equal(liveCalls,0);await assert.rejects(()=>service.details(user(),{month:'2026-10'}),HistoryPeriodUnavailable);
|
||||
});
|
||||
test('snapshot count integrity failure never appears as a legitimate zero', async () => {
|
||||
const {store,service}=setup();store.rows.clear();await assert.rejects(()=>service.trend(user(),{}),/integrity/);
|
||||
});
|
||||
test('invalid parameters fail before querying details or live; valid archived zero retains detail access', async () => {
|
||||
const {store,service}=setup();store.add('2026-08',[]);
|
||||
assert.equal((await service.details(user(),{month:'2026-08'})).total,0);
|
||||
for(const params of [{year:'NaN'},{year:'2027'},{mode:'weekly'}] as Record<string,string>[]) await assert.rejects(()=>service.trend(user(),params),HistoryInputError);
|
||||
for(const params of [{month:'2026-13'},{month:'2026-09',page:'0'},{month:'2026-09',pageSize:'101'},{month:'2026-09',category:'hacked'}] as Record<string,string>[]) await assert.rejects(()=>service.details(user(),params),HistoryInputError);
|
||||
});
|
||||
test('HTTP router authenticates, validates and exposes only read routes with private no-store', async () => {
|
||||
const {service}=setup();const router=createHistoryRouter(service);
|
||||
assert.equal((await router.request('/trend')).status,401);
|
||||
const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user('personal'));await next();});app.route('/',router);
|
||||
const response=await app.request('/trend?year=2026');assert.equal(response.status,200);assert.match(response.headers.get('Cache-Control')!,/private.*no-store/);
|
||||
assert.equal((await app.request('/details?month=2026-08')).status,409);
|
||||
assert.equal((await app.request('/trend?year=x')).status,400);
|
||||
assert.equal((await app.request('/details?month=2026-09&batchId=forged')).status,409);
|
||||
assert.equal((await app.request('/capture',{method:'POST'})).status,404);
|
||||
});
|
||||
test('permission-preview identity applies to history GET; capture/import writes remain blocked', async () => {
|
||||
const {service}=setup();const audit:string[]=[];
|
||||
const preview={current:async()=>({user:user('personal'),sessionId:'test-session'}),audit:async(event:{event:string})=>{audit.push(event.event);}};
|
||||
const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user());await next();});
|
||||
app.use('*',createPreviewMiddleware(preview as never));app.route('/api/asset-history',createHistoryRouter(service));
|
||||
const response=await app.request('/api/asset-history/details?month=2026-09',{headers:{'X-BI-Preview':'isolated-test'}});
|
||||
assert.equal(response.status,200);assert.equal((await response.json()).total,2);
|
||||
assert.equal((await app.request('/api/asset-history/import',{method:'POST',headers:{'X-BI-Preview':'isolated-test'}})).status,403);
|
||||
assert.deepEqual(audit,['read','blocked-write']);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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 })) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Pool } from 'mysql2/promise';
|
||||
import { MysqlHistoryStore } from './store.js';
|
||||
import { MAIN_SQL } from '../vehicles/repository.js';
|
||||
import { HistoryStorageUnavailable } from './model.js';
|
||||
import { testRow } from './fixtures.js';
|
||||
|
||||
/** Isolated transactional database double: failed transactions discard ALL staged writes. */
|
||||
class Database {
|
||||
commands:string[]=[];
|
||||
rows=[testRow('9223372036854775000'),testRow('stock','3')];
|
||||
settings='2026-10-19T13:00:00+08:00';
|
||||
locked=1; existing: Array<{id:string;status:string}>=[];
|
||||
committed:string[]=[]; staged:string[]=[];
|
||||
failDetails=0; failReads=false; failCommitAck=false; beforeLock?:()=>void; afterSource?:()=>void; onReadTransaction?:()=>void;
|
||||
released=false; clock=()=>new Date('2026-10-31T17:00:00.500+08:00');
|
||||
async query(sql:string,_args?:unknown[]) {
|
||||
this.commands.push(sql);
|
||||
if(sql.includes('GET_LOCK')){this.beforeLock?.();return[[{acquired:this.locked}]];}
|
||||
if(sql.includes('RELEASE_LOCK'))return[[{released:1}]];
|
||||
if(sql.includes('FROM bi_asset_history_settings'))return[[{enabledAt:this.settings}]];
|
||||
if(sql.includes('SELECT id,status'))return[this.existing];
|
||||
if(sql.startsWith('START TRANSACTION'))this.onReadTransaction?.();
|
||||
if(sql===MAIN_SQL){if(this.failReads)throw new Error('read failed');this.afterSource?.();return[this.rows];}
|
||||
if(sql.startsWith('INSERT INTO bi_asset_history_vehicles')){
|
||||
this.staged.push('details');if(this.failDetails-->0)throw new Error('write failed');
|
||||
}
|
||||
if(sql.startsWith('INSERT INTO bi_asset_history_batches'))this.staged.push('batch');
|
||||
if(sql.startsWith('UPDATE bi_asset_history_batches'))this.staged.push('complete');
|
||||
return[[]];
|
||||
}
|
||||
async getConnection(){return this;}
|
||||
async beginTransaction(){this.commands.push('BEGIN');this.staged=[];}
|
||||
async rollback(){this.commands.push('ROLLBACK');this.staged=[];}
|
||||
async commit(){this.commands.push('COMMIT');this.committed.push(...this.staged);this.staged=[];if(this.failCommitAck){this.failCommitAck=false;this.existing=[{id:'committed',status:'COMPLETE'}];throw new Error('commit acknowledgement lost');}}
|
||||
release(){this.released=true;}
|
||||
store(){return new MysqlHistoryStore(this as unknown as Pool);}
|
||||
}
|
||||
test('capture uses uncached shared SQL and one consistent read; detail/header publish atomically',async()=>{
|
||||
const db=new Database();assert.equal(await db.store().capture('2026-10',db.clock),'captured');
|
||||
assert.deepEqual(db.committed,['batch','details','complete']);
|
||||
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
||||
assert.ok(db.commands.indexOf('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')<db.commands.indexOf(MAIN_SQL));
|
||||
assert.ok(db.commands.indexOf(MAIN_SQL)<db.commands.indexOf('ROLLBACK'));
|
||||
assert.equal(db.released,true);assert.ok(db.commands.at(-1)!.includes('RELEASE_LOCK'));
|
||||
});
|
||||
test('write retry preserves the exact captured rows and time, even after the minute expires',async()=>{
|
||||
const db=new Database();db.failDetails=1;let now=new Date('2026-10-31T17:00:30+08:00');
|
||||
db.afterSource=()=>{now=new Date('2026-10-31T18:00:00+08:00');};const clock=()=>now;
|
||||
assert.equal(await db.store().capture('2026-10',clock),'captured');
|
||||
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
||||
assert.deepEqual(db.committed,['batch','details','complete']);
|
||||
assert.equal(db.commands.filter(s=>s==='BEGIN').length,2);
|
||||
});
|
||||
test('lost commit acknowledgement does not overwrite or duplicate an already-published batch',async()=>{
|
||||
const db=new Database();db.failCommitAck=true;
|
||||
assert.equal(await db.store().capture('2026-10',db.clock),'already-complete');
|
||||
assert.deepEqual(db.committed,['batch','details','complete']);assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
||||
});
|
||||
test('source is not read if transaction establishment crosses the capture deadline',async()=>{
|
||||
const db=new Database();let now=new Date('2026-10-31T17:00:59.900+08:00');
|
||||
db.onReadTransaction=()=>{now=new Date('2026-10-31T17:01:00+08:00');};
|
||||
assert.equal(await db.store().capture('2026-10',()=>now),'outside-window');
|
||||
assert.equal(db.commands.includes(MAIN_SQL),false);assert.deepEqual(db.committed,[]);
|
||||
});
|
||||
test('persistent detail-write failure publishes neither partial header nor rows',async()=>{
|
||||
const db=new Database();db.failDetails=5;
|
||||
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/write failed/);
|
||||
assert.deepEqual(db.committed,[]);assert.deepEqual(db.staged,[]);assert.equal(db.released,true);
|
||||
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
||||
});
|
||||
test('source failure and invalid rows cannot publish snapshots',async()=>{
|
||||
const db=new Database();db.failReads=true;
|
||||
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/read failed/);assert.deepEqual(db.committed,[]);
|
||||
db.failReads=false;db.rows=[testRow('duplicate'),testRow('duplicate')];
|
||||
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/重复/);assert.deepEqual(db.committed,[]);
|
||||
});
|
||||
test('completed period, concurrent lock and expired window cannot issue a source read',async()=>{
|
||||
for(const scenario of ['complete','lock','expired','delayed-lock','late-activation']){
|
||||
const db=new Database();let clock=db.clock;
|
||||
if(scenario==='complete')db.existing=[{id:'existing',status:'COMPLETE'}];
|
||||
if(scenario==='lock')db.locked=0;
|
||||
if(scenario==='expired')clock=()=>new Date('2026-10-31T17:01:00+08:00');
|
||||
if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T17:01:00+08:00');};
|
||||
if(scenario==='late-activation')db.settings='2026-10-31T17:00:01+08:00';
|
||||
const result=await db.store().capture('2026-10',()=>clock());
|
||||
assert.ok(['already-complete','locked','outside-window'].includes(result));
|
||||
assert.equal(db.commands.includes(MAIN_SQL),false,scenario);assert.deepEqual(db.committed,[]);
|
||||
}
|
||||
});
|
||||
test('a legitimate empty source commits a zero-count complete batch without detail inserts',async()=>{
|
||||
const db=new Database();db.rows=[];
|
||||
assert.equal(await db.store().capture('2026-10',db.clock),'captured');assert.deepEqual(db.committed,['batch','complete']);
|
||||
});
|
||||
test('missing storage reads never issue schema writes and report storage-unavailable',async()=>{
|
||||
const calls:string[]=[];
|
||||
const db={query:async(sql:string)=>{calls.push(sql);throw{code:'ER_NO_SUCH_TABLE'};}};
|
||||
const store=new MysqlHistoryStore(db as unknown as Pool);
|
||||
await assert.rejects(()=>store.settings(),HistoryStorageUnavailable);
|
||||
await assert.rejects(()=>store.batches(),HistoryStorageUnavailable);
|
||||
assert.ok(calls.every(sql=>sql.startsWith('SELECT')));
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Pool, PoolConnection, RowDataPacket } from 'mysql2/promise';
|
||||
import { CAPTURE_WINDOW_MS, HISTORY_VERSION, monthCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js';
|
||||
import type { VehicleRow } from '../../types.js';
|
||||
import { MAIN_SQL } from '../vehicles/repository.js';
|
||||
import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
|
||||
|
||||
export interface HistoryReader {
|
||||
settings(): Promise<HistorySettings | null>;
|
||||
batches(): Promise<HistoryBatch[]>;
|
||||
vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>>;
|
||||
}
|
||||
export type CaptureResult = 'captured' | 'already-complete' | 'locked' | 'outside-window';
|
||||
const sqlTime = (date: Date) => shanghaiTimestamp(date).slice(0, 23).replace('T', ' ');
|
||||
const dateColumn = (name: string) => `DATE_FORMAT(${name}, '%Y-%m-%dT%H:%i:%s.%f+08:00')`;
|
||||
|
||||
/** No schema writes in constructor or any read. All lifecycle writes are explicit. */
|
||||
export class MysqlHistoryStore implements HistoryReader {
|
||||
constructor(private readonly db: Pick<Pool, 'query' | 'getConnection'>) {}
|
||||
|
||||
private async read(sql: string, args: unknown[] = []): Promise<RowDataPacket[]> {
|
||||
try { const [rows] = await this.db.query<RowDataPacket[]>(sql, args); return rows; }
|
||||
catch (error) {
|
||||
if ((error as { code?: string }).code === 'ER_NO_SUCH_TABLE') throw new HistoryStorageUnavailable('历史快照存储尚未部署');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async settings(): Promise<HistorySettings | null> {
|
||||
const rows = await this.read(`SELECT ${dateColumn('enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
|
||||
return rows[0] ? { enabledAt: rows[0].enabledAt } : null;
|
||||
}
|
||||
async batches(): Promise<HistoryBatch[]> {
|
||||
const rows = await this.read(`SELECT id, period_month 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=? ORDER BY period_month`, [HISTORY_VERSION]);
|
||||
return 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 } }));
|
||||
}
|
||||
async vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>> {
|
||||
const result = new Map<string, HistoryVehicle[]>(batchIds.map(id => [id, []]));
|
||||
if (!batchIds.length) return result;
|
||||
const rows = await this.read(`SELECT batch_id, frozen_vehicle FROM bi_asset_history_vehicles WHERE batch_id IN (${batchIds.map(() => '?').join(',')}) ORDER BY vehicle_id`, batchIds);
|
||||
for (const row of rows) {
|
||||
const value = typeof row.frozen_vehicle === 'string' ? JSON.parse(row.frozen_vehicle) : row.frozen_vehicle;
|
||||
if (!value || typeof value.id !== 'string') throw new Error('Invalid archived vehicle');
|
||||
result.get(row.batch_id)!.push(value as HistoryVehicle);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async activate(now: Date): Promise<HistorySettings> {
|
||||
await this.db.query('INSERT IGNORE INTO bi_asset_history_settings (id,enabled_at) VALUES (1,?)', [sqlTime(now)]);
|
||||
return (await this.settings())!;
|
||||
}
|
||||
async recordOutcome(month: string, status: 'FAILED' | 'MISSED', code: string): Promise<void> {
|
||||
// Never downgrade a completed snapshot, or replace a useful failure with MISSED.
|
||||
await this.db.query(`INSERT INTO bi_asset_history_batches
|
||||
(id,period_month,definition_version,scheduled_for,source,status,failure_code)
|
||||
VALUES (?,?,?,?,'AUTO',?,?) ON DUPLICATE KEY UPDATE
|
||||
failure_code=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),failure_code,VALUES(failure_code)),
|
||||
status=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),status,VALUES(status))`,
|
||||
[randomUUID(), month, HISTORY_VERSION, sqlTime(monthCutoff(month)), status, code]);
|
||||
}
|
||||
async capture(month: string, clock: () => Date = () => new Date()): Promise<CaptureResult> {
|
||||
const cutoff = monthCutoff(month).getTime();
|
||||
const inWindow = () => clock().getTime() >= cutoff && clock().getTime() < cutoff + CAPTURE_WINDOW_MS;
|
||||
if (!inWindow()) return 'outside-window';
|
||||
const connection = await this.db.getConnection();
|
||||
const lock = `ln-bi:asset:${HISTORY_VERSION}:${month}`;
|
||||
let locked = false;
|
||||
try {
|
||||
const [locks] = await connection.query<RowDataPacket[]>("SELECT GET_LOCK(CONCAT(DATABASE(),':',?),0) AS acquired", [lock]);
|
||||
locked = Number(locks[0]?.acquired) === 1;
|
||||
if (!locked) return 'locked';
|
||||
const settings = await this.settings();
|
||||
if (!settings || new Date(settings.enabledAt).getTime() > cutoff || !inWindow()) return 'outside-window';
|
||||
const [existing] = await connection.query<RowDataPacket[]>(`SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=?`, [month, HISTORY_VERSION]);
|
||||
if (existing[0]?.status === 'COMPLETE') return 'already-complete';
|
||||
await connection.query("SET time_zone='+08:00'");
|
||||
await connection.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
||||
if (!inWindow()) return 'outside-window';
|
||||
await connection.query('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY');
|
||||
const capturedAt = clock();
|
||||
let frozen: HistoryVehicle[];
|
||||
try {
|
||||
if (!inWindow()) return 'outside-window';
|
||||
const [rows] = await connection.query<RowDataPacket[]>(MAIN_SQL);
|
||||
frozen = freezeRows(rows as VehicleRow[]);
|
||||
} finally { await connection.rollback(); }
|
||||
// Retries below keep this exact frozen dataset. Never reread a later live ledger.
|
||||
const id = existing[0]?.id || randomUUID();
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { return await this.publish(connection, id, month, capturedAt, frozen, clock); }
|
||||
catch (error) { await connection.rollback(); if (attempt >= 2) throw error; }
|
||||
}
|
||||
} finally {
|
||||
try { if (locked) await connection.query("SELECT RELEASE_LOCK(CONCAT(DATABASE(),':',?))", [lock]); }
|
||||
finally { connection.release(); }
|
||||
}
|
||||
}
|
||||
private async publish(connection: PoolConnection, id: string, month: string, capturedAt: Date, vehicles: HistoryVehicle[], clock: () => Date) {
|
||||
const totals = counts(vehicles);
|
||||
await connection.beginTransaction();
|
||||
const [existing] = await connection.query<RowDataPacket[]>('SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=? FOR UPDATE', [month, HISTORY_VERSION]);
|
||||
// Also handles a committed transaction whose acknowledgement was lost.
|
||||
if (existing[0]?.status === 'COMPLETE') { await connection.commit(); return 'already-complete' as const; }
|
||||
if (existing[0]) id = existing[0].id;
|
||||
// Under the advisory lock, only FAILED/MISSED placeholders may be promoted.
|
||||
await connection.query(`INSERT INTO bi_asset_history_batches
|
||||
(id,period_month,definition_version,scheduled_for,captured_at,source,status,total_count,operating_count,inventory_count)
|
||||
VALUES (?,?,?,?,?,'AUTO','FAILED',?,?,?) ON DUPLICATE KEY UPDATE captured_at=VALUES(captured_at),
|
||||
total_count=VALUES(total_count),operating_count=VALUES(operating_count),inventory_count=VALUES(inventory_count)`,
|
||||
[id, month, HISTORY_VERSION, sqlTime(monthCutoff(month)), sqlTime(capturedAt), totals.total, totals.operating, totals.inventory]);
|
||||
for (let offset = 0; offset < vehicles.length; offset += 100) {
|
||||
const chunk = vehicles.slice(offset, offset + 100);
|
||||
await connection.query(`INSERT INTO bi_asset_history_vehicles
|
||||
(batch_id,vehicle_id,operation_status,vehicle_status,disposition,subject_org,department_name,manager_id,frozen_vehicle)
|
||||
VALUES ${chunk.map(() => '(?,?,?,?,?,?,?,?,CAST(? AS JSON))').join(',')}`, chunk.flatMap(v =>
|
||||
[id, v.id, v.operationStatus, v.vehicleStatus, v.disposition, v.subjectOrg, v.departmentName, v.managerId, JSON.stringify(v)]));
|
||||
}
|
||||
await connection.query("UPDATE bi_asset_history_batches SET status='COMPLETE',completed_at=?,failure_code=NULL WHERE id=?", [sqlTime(clock()), id]);
|
||||
await connection.commit();
|
||||
return 'captured' as const;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export interface VehicleRow {
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
vehicleStatus?: string | null;
|
||||
opsManagers?: string | null;
|
||||
pendingDelivery?: { contractNo: string | null; customerName: string | null } | null;
|
||||
abnormalMove?: AbnormalMoveDetail | null;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
export type HistoryMode = '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 }
|
||||
export interface HistoryPoint {
|
||||
period: string;
|
||||
label: string;
|
||||
month: string;
|
||||
scheduledFor: string;
|
||||
asOf: string | null;
|
||||
state: HistoryState;
|
||||
source: 'AUTO' | 'IMPORT' | 'LIVE' | 'RECONSTRUCTED' | null;
|
||||
batchId: string | null;
|
||||
counts: HistoryCounts | null;
|
||||
difference: HistoryCounts | null;
|
||||
comparisonLabel: string;
|
||||
detailAvailable: boolean;
|
||||
reconstruction?: {
|
||||
operating: number; inventory: number; unclassified: number;
|
||||
currentlyExited: number; sourceAsOf: string; sourceReference: string;
|
||||
workbookSha256: string;
|
||||
difference: { operating: number; inventory: number } | null;
|
||||
};
|
||||
}
|
||||
export interface HistoryTrend {
|
||||
mode: HistoryMode;
|
||||
year: number;
|
||||
points: HistoryPoint[];
|
||||
years: number[];
|
||||
subjects: string[];
|
||||
storageReady: boolean;
|
||||
enabledAt: string | null;
|
||||
nextCaptureAt: string | null;
|
||||
warning: string | null;
|
||||
}
|
||||
export interface HistoryDetailItem {
|
||||
id: string; plateNumber: string; vin: string; type: string; model: string;
|
||||
disposition: 'operating' | 'inventory'; operationStatus: string | null;
|
||||
vehicleStatus: string | null; subjectOrg: string | null; departmentName: string | null;
|
||||
customerManager: string | null; customerName: string | null; contractNo: string | null;
|
||||
reconstructionBasis?: string;
|
||||
}
|
||||
export interface HistoryDetails {
|
||||
point: HistoryPoint;
|
||||
counts: HistoryCounts | null;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: HistoryDetailItem[];
|
||||
}
|
||||
|
||||
const OFFSET = 8 * 60 * 60 * 1000;
|
||||
export const CAPTURE_WINDOW_MS = 60_000;
|
||||
export const HISTORY_VERSION = 'asset-v1';
|
||||
export function shanghaiMonth(now: Date): string { return new Date(now.getTime() + OFFSET).toISOString().slice(0, 7); }
|
||||
export function shanghaiTimestamp(now: Date): string { return new Date(now.getTime() + OFFSET).toISOString().slice(0, -1) + '+08:00'; }
|
||||
export function monthCutoff(month: string): Date {
|
||||
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效');
|
||||
const [year, m] = month.split('-').map(Number);
|
||||
return new Date(Date.UTC(year, m, 0, 9)); // 17:00 Asia/Shanghai, on the last day.
|
||||
}
|
||||
export function shiftMonth(month: string, offset: number): string {
|
||||
const [year, m] = month.split('-').map(Number);
|
||||
return new Date(Date.UTC(year, m - 1 + offset, 1)).toISOString().slice(0, 7);
|
||||
}
|
||||
export function nextCapture(now: Date): string {
|
||||
const month = shanghaiMonth(now);
|
||||
return shanghaiTimestamp(monthCutoff(monthCutoff(month).getTime() > now.getTime() ? month : shiftMonth(month, 1)));
|
||||
}
|
||||
export function historyTime(value: string | null): string {
|
||||
if (!value) return '暂无时间';
|
||||
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
|
||||
}
|
||||
export const HISTORY_STATE_LABELS: Record<HistoryState, string> = {
|
||||
LIVE: '未结期 · 实时', ARCHIVED: '已归档', MISSING: '暂无快照', FUTURE: '尚未到达', CAPTURING: '归档中', FAILED: '归档失败', RECONSTRUCTED: '历史回溯 · 估算',
|
||||
};
|
||||
Reference in New Issue
Block a user