feat(assets): 展示近七天资产趋势并支持业务回溯归档
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE bi_asset_history_batches MODIFY COLUMN source ENUM('AUTO','IMPORT','业务回溯') NOT NULL;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState, type MouseEvent } from 'react';
|
import { useCallback, useEffect, useState, type MouseEvent } from 'react';
|
||||||
import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts';
|
import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts';
|
||||||
import { ChevronRight, RefreshCw } from 'lucide-react';
|
import { ChevronRight, RefreshCw } from 'lucide-react';
|
||||||
import { HISTORY_STATE_LABELS, historyTime, shanghaiMonth, type HistoryMode, type HistoryPoint, type HistoryTrend } from '../../../shared/assets/history';
|
import { HISTORY_STATE_LABELS, historyTime, shanghaiMonth, shanghaiDay, type HistoryMode, type HistoryPoint, type HistoryTrend } from '../../../shared/assets/history';
|
||||||
import { fetchHistoryLive, fetchHistoryTrend } from './api';
|
import { fetchHistoryLive, fetchHistoryTrend } from './api';
|
||||||
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation';
|
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation';
|
||||||
import { HistoryDetailsDialog } from './HistoryDetailsDialog';
|
import { HistoryDetailsDialog } from './HistoryDetailsDialog';
|
||||||
@@ -9,7 +9,7 @@ import { HistorySelect } from './HistorySelect';
|
|||||||
import './history.css';
|
import './history.css';
|
||||||
|
|
||||||
export function AssetHistorySection({ subject, onSubjects }: { subject: string | null; onSubjects?: (names: string[]) => void }) {
|
export function AssetHistorySection({ subject, onSubjects }: { subject: string | null; onSubjects?: (names: string[]) => void }) {
|
||||||
const [mode, setMode] = useState<HistoryMode>('monthly');
|
const [mode, setMode] = useState<HistoryMode>('daily');
|
||||||
const [year, setYear] = useState(() => Number(shanghaiMonth(new Date()).slice(0, 4)));
|
const [year, setYear] = useState(() => Number(shanghaiMonth(new Date()).slice(0, 4)));
|
||||||
const [data, setData] = useState<HistoryTrend | null>(null);
|
const [data, setData] = useState<HistoryTrend | null>(null);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
@@ -23,7 +23,8 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
|
|||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
let current: HistoryTrend | null = null;
|
let current: HistoryTrend | null = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let observedMonth = shanghaiMonth(new Date());
|
const calendarKey = mode === 'daily' ? shanghaiDay : shanghaiMonth;
|
||||||
|
let observedMonth = calendarKey(new Date());
|
||||||
setData(null); setSelected(null); setDetails(null); setError(''); setLoading(true);
|
setData(null); setSelected(null); setDetails(null); setError(''); setLoading(true);
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -38,7 +39,7 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
|
|||||||
void load();
|
void load();
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
if (busy || !current || abort.signal.aborted) return;
|
if (busy || !current || abort.signal.aborted) return;
|
||||||
const month = shanghaiMonth(new Date());
|
const month = calendarKey(new Date());
|
||||||
if (month !== observedMonth) { observedMonth = month; await load(); return; }
|
if (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 === 'CAPTURING')) { await load(); return; }
|
||||||
if (!current.points.some(p => p.state === 'LIVE')) return;
|
if (!current.points.some(p => p.state === 'LIVE')) return;
|
||||||
@@ -64,26 +65,26 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
|
|||||||
};
|
};
|
||||||
return <section className="asset-history" aria-labelledby="asset-history-title" data-testid="asset-history">
|
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-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>
|
<div className="asset-history-modes" role="group" aria-label="历史统计方式">{(['daily', 'monthly', 'annual'] as const).map(value => <button type="button" key={value} aria-pressed={mode === value} onClick={() => { setMode(value); setDetails(null); }}>{value === 'daily' ? '近7天' : value === 'monthly' ? '月度' : '年度'}</button>)}</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="asset-history-toolbar">
|
<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); }} />}
|
{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] }))} />}
|
{data && <HistorySelect label="统计期间" value={selected || ''} onChange={value => { setSelected(value); setDetails(null); }} options={data.points.map(p => ({ value: p.period, label: p.label, detail: p.source === '业务回溯' ? '业务回溯' : p.state === 'RECONSTRUCTED' ? '估算' : p.state === 'LIVE' ? '实时' : p.state === 'ARCHIVED' ? '归档' : p.state === 'FUTURE' ? '未来' : p.state === 'MISSING' ? '缺失' : HISTORY_STATE_LABELS[p.state] }))} />}
|
||||||
<span className="asset-history-cutoff" title="每天北京时间23:55采集;月度取月末、年度取年末快照">每日 23:55</span>
|
<span className="asset-history-cutoff" title="每天北京时间23:55采集;月度取月末、年度取年末快照">每日 23:55</span>
|
||||||
<button type="button" className="asset-history-refresh" aria-label="刷新资产历史趋势" disabled={loading} onClick={() => setRetry(x => x + 1)}><RefreshCw size={15} /></button>
|
<button type="button" className="asset-history-refresh" aria-label="刷新资产历史趋势" disabled={loading} onClick={() => setRetry(x => x + 1)}><RefreshCw size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
</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> : <>
|
{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>}
|
{error && <p className="asset-history-warning" role="alert">{error} · 保留上次查询结果 <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p>}
|
||||||
<div className="asset-history-chart" role="img" aria-label="运营数与库存数堆叠柱,资产总数折线;精确数值和缺失状态可通过上方统计期间选择框查看">
|
<div className="asset-history-chart" role="img" aria-label="运营数与库存数堆叠柱,资产总数折线">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}>
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}>
|
||||||
<ComposedChart data={chart} margin={{ top: 18, right: 10, bottom: 0, left: 0 }} onClick={state => { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}>
|
<ComposedChart data={chart} margin={{ top: 18, right: mode === 'daily' ? 18 : 10, 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} />
|
<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} tickCount={4} width={36} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
<YAxis allowDecimals={false} tickCount={4} width={36} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||||
<Tooltip content={({ active, payload }) => {
|
<Tooltip content={({ active, payload }) => {
|
||||||
const item = payload?.[0]?.payload as HistoryPoint | undefined;
|
const item = payload?.[0]?.payload as HistoryPoint | undefined;
|
||||||
const values = item && displayHistoryCounts(item);
|
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;
|
return active && item ? <div className="asset-history-tooltip"><strong>{item.label} · {item.source === '业务回溯' ? '业务回溯' : 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="operating" name="运营数" stackId="assets" fill="#347df5" maxBarSize={32} isAnimationActive={false} />
|
||||||
<Bar onClick={selectBar} dataKey="inventory" name="库存数" stackId="assets" fill="#9ed1fa" maxBarSize={32} isAnimationActive={false} />
|
<Bar onClick={selectBar} dataKey="inventory" name="库存数" stackId="assets" fill="#9ed1fa" maxBarSize={32} isAnimationActive={false} />
|
||||||
@@ -91,9 +92,9 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
|
|||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-history-legend" aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span><span className="asset-history-unit">台</span></div>
|
<div className="asset-history-legend" data-mode={mode} aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span><span className="asset-history-unit">台</span></div>
|
||||||
{point && <div className="asset-history-selected" aria-live="polite">
|
{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></div>
|
<div className="asset-history-selected-heading"><strong>{mode !== 'monthly' || point.state === 'LIVE' ? point.label : `${point.label}末`}</strong><span>{point.source === '业务回溯' ? '业务回溯' : HISTORY_STATE_LABELS[point.state]}</span></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>
|
<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 => {
|
{hasComparison ? <div className="asset-history-comparison"><span>{point.comparisonLabel}</span><div>{(['total', 'inventory', 'operating'] as const).map(key => {
|
||||||
const difference = displayHistoryDifference(point, key);
|
const difference = displayHistoryDifference(point, key);
|
||||||
@@ -101,10 +102,10 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
|
|||||||
})}</div></div> : null}
|
})}</div></div> : null}
|
||||||
<div className="asset-history-selected-footer">
|
<div className="asset-history-selected-footer">
|
||||||
<div className="asset-history-point-meta">{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}{!hasComparison && <span>{point.comparisonLabel}</span>}</div>
|
<div className="asset-history-point-meta">{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}{!hasComparison && <span>{point.comparisonLabel}</span>}</div>
|
||||||
{mode === 'monthly' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={16} aria-hidden="true" /></button>}
|
{mode !== 'annual' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{mode === 'daily' ? '查看当日明细' : point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={16} aria-hidden="true" /></button>}
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
<p className="asset-history-footnote">历史库存状态快照缺失,核对中,暂不展示。</p>
|
{data.points.some(p => p.state === 'MISSING') && <p className="asset-history-footnote">历史库存状态快照缺失,核对中,暂不展示。</p>}
|
||||||
</>}
|
</>}
|
||||||
{details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />}
|
{details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />}
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -24,25 +24,25 @@ export function HistoryDetailsDialog({ point, subject, close }: { point: History
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
setLoading(true); setError(''); setData(null);
|
setLoading(true); setError(''); setData(null);
|
||||||
fetchHistoryDetails(point.month, point.batchId, subject, category, query, page, abort.signal)
|
fetchHistoryDetails(point.period, point.batchId, subject, category, query, page, abort.signal)
|
||||||
.then(result => { if (!abort.signal.aborted) setData(result); })
|
.then(result => { if (!abort.signal.aborted) setData(result); })
|
||||||
.catch(e => { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '明细加载失败'); })
|
.catch(e => { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '明细加载失败'); })
|
||||||
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||||
return () => abort.abort();
|
return () => abort.abort();
|
||||||
}, [point.month, point.batchId, subject, category, query, page, retry]);
|
}, [point.period, point.batchId, subject, category, query, page, retry]);
|
||||||
const pages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
|
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(); }}>
|
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}>
|
<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>
|
<header><div><h2 id="asset-history-detail-title">{point.period.length === 10 ? point.period : 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>
|
<p>{subject || '全部公司'} · {point.source === '业务回溯' ? '业务回溯' : 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>
|
<button type="button" onClick={close} aria-label="关闭资产明细"><X size={20} /></button></header>
|
||||||
<div className="asset-history-detail-tools">
|
<div className="asset-history-detail-tools">
|
||||||
<label>车辆分类<select aria-label="月度明细分类" value={category} onChange={e => { setCategory(e.target.value as HistoryCategory); setPage(1); }}>
|
<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>
|
<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>
|
<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>
|
||||||
<div className="asset-history-detail-body" aria-busy={loading} aria-live="polite">
|
<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 && <>
|
{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>
|
<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>
|
{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>
|
<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>
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ export function fetchHistoryLive(mode: HistoryMode, subject: string | null, sign
|
|||||||
return get<HistoryPoint>('live', { mode, subject }, signal);
|
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) {
|
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);
|
return get<HistoryDetails>('details', { [month.length === 10 ? 'day' : 'month']: month, batchId, subject, category, search, page, pageSize: 30 }, signal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function updateLivePoint(points: HistoryPoint[], live: HistoryPoint): His
|
|||||||
total: live.counts.total - baseline.total,
|
total: live.counts.total - baseline.total,
|
||||||
operating: live.counts.operating - baseline.operating,
|
operating: live.counts.operating - baseline.operating,
|
||||||
inventory: live.counts.inventory - baseline.inventory,
|
inventory: live.counts.inventory - baseline.inventory,
|
||||||
} : null, comparisonLabel: baseline ? `截至当前较${live.period.length === 4 ? '上年末' : '上月末'}` : '暂无可比数据' };
|
} : null, comparisonLabel: baseline ? `截至当前较${live.period.length === 4 ? '上年末' : live.period.length === 10 ? '昨日' : '上月末'}` : '暂无可比数据' };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export const signedCount = (value: number) => value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString();
|
export const signedCount = (value: number) => value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString();
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import { filterAssetVehiclesByPermission, maskCustomerNames } from '../../auth/p
|
|||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||||
import { isOperatingVehicle, isInventoryVehicle, transformRow } from '../vehicles/model.js';
|
import { isOperatingVehicle, isInventoryVehicle, transformRow } from '../vehicles/model.js';
|
||||||
import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiTimestamp, type HistoryCounts, type HistoryPoint } from '../../../shared/assets/history.js';
|
import { CAPTURE_WINDOW_MS, historyCutoff, shanghaiTimestamp, type HistoryCounts, type HistoryPoint } from '../../../shared/assets/history.js';
|
||||||
|
|
||||||
export interface HistoryVehicle extends Vehicle {
|
export interface HistoryVehicle extends Vehicle {
|
||||||
|
businessBasis?: string;
|
||||||
vehicleStatus: string | null;
|
vehicleStatus: string | null;
|
||||||
disposition: 'operating' | 'inventory';
|
disposition: 'operating' | 'inventory';
|
||||||
}
|
}
|
||||||
export interface HistoryBatch {
|
export interface HistoryBatch {
|
||||||
id: string; month: string; status: 'COMPLETE' | 'FAILED' | 'MISSED';
|
id: string; month: string; status: 'COMPLETE' | 'FAILED' | 'MISSED';
|
||||||
source: 'AUTO' | 'IMPORT'; scheduledFor: string; capturedAt: string | null;
|
source: 'AUTO' | 'IMPORT' | '业务回溯'; scheduledFor: string; capturedAt: string | null;
|
||||||
counts: HistoryCounts;
|
counts: HistoryCounts;
|
||||||
}
|
}
|
||||||
export interface HistorySettings { enabledAt: string }
|
export interface HistorySettings { enabledAt: string }
|
||||||
@@ -47,9 +48,9 @@ export function counts(vehicles: Vehicle[]): HistoryCounts {
|
|||||||
return { total: vehicles.length, operating: vehicles.filter(isOperatingVehicle).length, inventory: vehicles.filter(isInventoryVehicle).length };
|
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 {
|
export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?: HistoryVehicle[], live?: HistoryVehicle[], annual = false): HistoryPoint {
|
||||||
const cutoff = batch ? new Date(batch.scheduledFor) : monthCutoff(month);
|
const cutoff = batch ? new Date(batch.scheduledFor) : historyCutoff(month);
|
||||||
let state: HistoryPoint['state'] = 'MISSING';
|
let state: HistoryPoint['state'] = 'MISSING';
|
||||||
if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, 7)) ? 'LIVE' : 'FUTURE';
|
if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, month.length)) ? 'LIVE' : 'FUTURE';
|
||||||
else if (batch?.status === 'COMPLETE') state = 'ARCHIVED';
|
else if (batch?.status === 'COMPLETE') state = 'ARCHIVED';
|
||||||
else if (batch?.status === 'FAILED') state = 'FAILED';
|
else if (batch?.status === 'FAILED') state = 'FAILED';
|
||||||
else if (now.getTime() < cutoff.getTime() + CAPTURE_WINDOW_MS && batch?.status !== 'MISSED') state = 'CAPTURING';
|
else if (now.getTime() < cutoff.getTime() + CAPTURE_WINDOW_MS && batch?.status !== 'MISSED') state = 'CAPTURING';
|
||||||
@@ -57,9 +58,9 @@ export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?:
|
|||||||
const pointCounts = state === 'ARCHIVED' ? counts(data || []) : state === 'LIVE' ? counts(live || []) : null;
|
const pointCounts = state === 'ARCHIVED' ? counts(data || []) : state === 'LIVE' ? counts(live || []) : null;
|
||||||
return {
|
return {
|
||||||
period: annual ? month.slice(0, 4) : month,
|
period: annual ? month.slice(0, 4) : month,
|
||||||
label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month,
|
label: annual ? `${month.slice(0, 4)}年` : month.length === 10 ? month.slice(5).replace('-', '/') : `${Number(month.slice(5))}月`, month: month.slice(0, 7),
|
||||||
scheduledFor: batch?.scheduledFor || shanghaiTimestamp(cutoff),
|
scheduledFor: batch?.scheduledFor || shanghaiTimestamp(cutoff),
|
||||||
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? batch!.capturedAt : null,
|
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? (batch!.source === '业务回溯' ? batch!.scheduledFor : batch!.capturedAt) : null,
|
||||||
state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null,
|
state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null,
|
||||||
batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts,
|
batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts,
|
||||||
difference: null, comparisonLabel: '暂无可比数据', detailAvailable: available,
|
difference: null, comparisonLabel: '暂无可比数据', detailAvailable: available,
|
||||||
@@ -75,7 +76,7 @@ export function comparePoint(point: HistoryPoint, previous: HistoryPoint | undef
|
|||||||
} : null } } : {}),
|
} : null } } : {}),
|
||||||
comparisonLabel: comparable ? `推算值较${annual ? '上年年末' : '上月末'}` : '推算与实时或正式快照口径不同,暂无可比数据' };
|
comparisonLabel: comparable ? `推算值较${annual ? '上年年末' : '上月末'}` : '推算与实时或正式快照口径不同,暂无可比数据' };
|
||||||
}
|
}
|
||||||
const label = annual ? '上年末' : '上月末';
|
const label = annual ? '上年末' : point.period.length === 10 ? '昨日' : '上月末';
|
||||||
return { ...point, difference: point.counts && previous?.counts ? {
|
return { ...point, difference: point.counts && previous?.counts ? {
|
||||||
total: point.counts.total - previous.counts.total,
|
total: point.counts.total - previous.counts.total,
|
||||||
inventory: point.counts.inventory - previous.counts.inventory,
|
inventory: point.counts.inventory - previous.counts.inventory,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import type { Vehicle } from '../../types.js';
|
import type { Vehicle } from '../../types.js';
|
||||||
import { isOperatingVehicle } from '../vehicles/model.js';
|
import { isOperatingVehicle } from '../vehicles/model.js';
|
||||||
import { HISTORY_STATE_LABELS, nextCapture, shanghaiMonth, shiftMonth, monthCutoff, type HistoryMode, type HistoryTrend, type HistoryCategory, type HistoryDetails } from '../../../shared/assets/history.js';
|
import { HISTORY_STATE_LABELS, nextCapture, shanghaiMonth, shiftMonth, historyCutoff, shiftDay, shanghaiDay, type HistoryMode, type HistoryTrend, type HistoryCategory, type HistoryDetails } from '../../../shared/assets/history.js';
|
||||||
import { authorizedVehicles, comparePoint, counts, freezeLive, HistoryStorageUnavailable, makePoint, validateVehicles, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
|
import { authorizedVehicles, comparePoint, counts, freezeLive, HistoryStorageUnavailable, makePoint, validateVehicles, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
|
||||||
import type { HistoryReader } from './store.js';
|
import type { HistoryReader } from './store.js';
|
||||||
import { reconstructionItems, reconstructionPoint, type ReconstructionReader } from './reconstruction.js';
|
import { reconstructionItems, reconstructionPoint, type ReconstructionReader } from './reconstruction.js';
|
||||||
@@ -11,7 +11,7 @@ export class HistoryPeriodUnavailable extends Error {}
|
|||||||
export function parseHistoryQuery(params: Record<string, string>, now: Date) {
|
export function parseHistoryQuery(params: Record<string, string>, now: Date) {
|
||||||
const mode = params.mode || 'monthly';
|
const mode = params.mode || 'monthly';
|
||||||
const year = Number(params.year || shanghaiMonth(now).slice(0, 4));
|
const year = Number(params.year || shanghaiMonth(now).slice(0, 4));
|
||||||
if (!['monthly', 'annual'].includes(mode) || !Number.isInteger(year) || year < 2000 || year > Number(shanghaiMonth(now).slice(0, 4))) throw new HistoryInputError('年份或统计方式无效');
|
if (!['daily', 'monthly', 'annual'].includes(mode) || !Number.isInteger(year) || year < 2000 || year > Number(shanghaiMonth(now).slice(0, 4))) throw new HistoryInputError('年份或统计方式无效');
|
||||||
if ((params.subject || '').length > 500) throw new HistoryInputError('公司筛选无效');
|
if ((params.subject || '').length > 500) throw new HistoryInputError('公司筛选无效');
|
||||||
return { mode: mode as HistoryMode, year, subject: params.subject?.trim() || undefined };
|
return { mode: mode as HistoryMode, year, subject: params.subject?.trim() || undefined };
|
||||||
}
|
}
|
||||||
@@ -27,23 +27,24 @@ export class HistoryService {
|
|||||||
async livePoint(user: AuthUser, params: Record<string, string>) {
|
async livePoint(user: AuthUser, params: Record<string, string>) {
|
||||||
const now = this.clock();
|
const now = this.clock();
|
||||||
const query = parseHistoryQuery(params, now);
|
const query = parseHistoryQuery(params, now);
|
||||||
const month = query.mode === 'annual' ? `${shanghaiMonth(now).slice(0, 4)}-12` : shanghaiMonth(now);
|
const month = query.mode === 'daily' ? shanghaiDay(now) : query.mode === 'annual' ? `${shanghaiMonth(now).slice(0, 4)}-12` : shanghaiMonth(now);
|
||||||
const rows = now < monthCutoff(month) ? authorizedVehicles(freezeLive(await this.live()), user, query.subject) : [];
|
const rows = now < historyCutoff(month) ? authorizedVehicles(freezeLive(await this.live()), user, query.subject) : [];
|
||||||
return makePoint(month, now, undefined, [], rows, query.mode === 'annual');
|
return makePoint(month, now, undefined, [], rows, query.mode === 'annual');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async archives(months: string[], catalog?: HistoryBatch[]) {
|
private async archives(months: string[], allowBusiness: boolean, catalog?: HistoryBatch[]) {
|
||||||
let settings: HistorySettings | null = null;
|
let settings: HistorySettings | null = null;
|
||||||
let batches: HistoryBatch[] = [];
|
let batches: HistoryBatch[] = [];
|
||||||
try {
|
try {
|
||||||
[settings, batches] = await Promise.all([this.store.settings(), catalog ? Promise.resolve(catalog) : this.store.batches()]);
|
[settings, batches] = await Promise.all([this.store.settings(), catalog ? Promise.resolve(catalog) : this.store.batches(months[0].length === 10 ? months : undefined)]);
|
||||||
|
batches = batches.filter(b => allowBusiness || b.source !== '业务回溯');
|
||||||
const selected = batches.filter(b => b.status === 'COMPLETE' && months.includes(b.month));
|
const selected = batches.filter(b => b.status === 'COMPLETE' && months.includes(b.month));
|
||||||
const vehicles = await this.store.vehicles(selected.map(b => b.id));
|
const vehicles = await this.store.vehicles(selected.map(b => b.id));
|
||||||
for (const batch of selected) {
|
for (const batch of selected) {
|
||||||
const rows = vehicles.get(batch.id) || [];
|
const rows = vehicles.get(batch.id) || [];
|
||||||
validateVehicles(rows);
|
validateVehicles(rows);
|
||||||
const totals = counts(rows);
|
const totals = counts(rows);
|
||||||
if (!batch.capturedAt || Object.entries(totals).some(([key, value]) => value !== batch.counts[key as keyof typeof totals]) || rows.some(v => v.disposition !== (isOperatingVehicle(v) ? 'operating' : 'inventory'))) throw new Error('Snapshot integrity mismatch');
|
if ((!batch.capturedAt && batch.source !== '业务回溯') || Object.entries(totals).some(([key, value]) => value !== batch.counts[key as keyof typeof totals]) || rows.some(v => v.disposition !== (isOperatingVehicle(v) ? 'operating' : 'inventory'))) throw new Error('Snapshot integrity mismatch');
|
||||||
}
|
}
|
||||||
return { settings, batches, vehicles, ready: true };
|
return { settings, batches, vehicles, ready: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -54,20 +55,21 @@ export class HistoryService {
|
|||||||
async trend(user: AuthUser, params: Record<string, string>): Promise<HistoryTrend> {
|
async trend(user: AuthUser, params: Record<string, string>): Promise<HistoryTrend> {
|
||||||
const now = this.clock();
|
const now = this.clock();
|
||||||
const query = parseHistoryQuery(params, now);
|
const query = parseHistoryQuery(params, now);
|
||||||
|
const days = query.mode === 'daily' ? Array.from({ length: 8 }, (_, i) => shiftDay(shanghaiDay(now), i - 7)) : undefined;
|
||||||
const currentYear = Number(shanghaiMonth(now).slice(0, 4));
|
const currentYear = Number(shanghaiMonth(now).slice(0, 4));
|
||||||
const reconstruction = await this.reconstructionData(user, query.subject);
|
const reconstruction = await this.reconstructionData(user, query.subject);
|
||||||
// Metadata is small (one batch/month); only relevant detail batches are loaded.
|
// Metadata is small (one batch/month); only relevant detail batches are loaded.
|
||||||
let catalog: HistoryBatch[] = [];
|
let catalog: HistoryBatch[] = [];
|
||||||
try { catalog = await this.store.batches(); } catch (e) { if (!(e instanceof HistoryStorageUnavailable)) throw e; }
|
try { catalog = await this.store.batches(days); } catch (e) { if (!(e instanceof HistoryStorageUnavailable)) throw e; }
|
||||||
const firstYear = Math.min(currentYear - 4, query.year, ...catalog.map(b => Number(b.month.slice(0, 4))), ...(reconstruction?.periods.map(p => Number(p.month.slice(0, 4))) || []));
|
const 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 years = Array.from({ length: currentYear - firstYear + 1 }, (_, i) => currentYear - i);
|
||||||
const months = query.mode === 'monthly'
|
const months = days ? days.slice(1) : query.mode === 'monthly'
|
||||||
? Array.from({ length: 12 }, (_, i) => `${query.year}-${String(i + 1).padStart(2, '0')}`)
|
? Array.from({ length: 12 }, (_, i) => `${query.year}-${String(i + 1).padStart(2, '0')}`)
|
||||||
: [...years].reverse().map(y => `${y}-12`);
|
: [...years].reverse().map(y => `${y}-12`);
|
||||||
const previous = query.mode === 'monthly' ? shiftMonth(months[0], -1) : `${firstYear - 1}-12`;
|
const previous = days ? days[0] : query.mode === 'monthly' ? shiftMonth(months[0], -1) : `${firstYear - 1}-12`;
|
||||||
const range = [previous, ...months];
|
const range = [previous, ...months];
|
||||||
const hasLive = months.some(m => monthCutoff(m) > now && (query.mode === 'annual' ? m.startsWith(String(currentYear)) : m === shanghaiMonth(now)));
|
const hasLive = months.some(m => historyCutoff(m) > now && (query.mode === 'annual' ? m.startsWith(String(currentYear)) : m === (days ? shanghaiDay(now) : shanghaiMonth(now))));
|
||||||
const [archive, rawLive] = await Promise.all([this.archives(range, catalog), hasLive ? this.live() : Promise.resolve([])]);
|
const [archive, rawLive] = await Promise.all([this.archives(range, user.permissionLevel === 'full' && !query.subject, catalog), hasLive ? this.live() : Promise.resolve([])]);
|
||||||
const live = freezeLive(rawLive);
|
const live = freezeLive(rawLive);
|
||||||
const subjects = new Set(authorizedVehicles(live, user).map(v => v.subjectOrg).filter((s): s is string => !!s));
|
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]));
|
const byMonth = new Map(archive.batches.map(batch => [batch.month, batch]));
|
||||||
@@ -76,7 +78,7 @@ export class HistoryService {
|
|||||||
const batch = byMonth.get(month);
|
const batch = byMonth.get(month);
|
||||||
const rows = batch?.status === 'COMPLETE' ? archive.vehicles.get(batch.id)! : [];
|
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');
|
const point = makePoint(month, now, batch, authorizedVehicles(rows, user, query.subject), authorizedVehicles(live, user, query.subject), query.mode === 'annual');
|
||||||
if (point.state === 'CAPTURING' && (!archive.settings || new Date(archive.settings.enabledAt) > monthCutoff(month))) point.state = 'MISSING';
|
if (point.state === 'CAPTURING' && (!archive.settings || new Date(archive.settings.enabledAt) > historyCutoff(month))) point.state = 'MISSING';
|
||||||
if (point.state === 'MISSING' && reconstruction) return reconstructionPoint(reconstruction, month, query.mode === 'annual', now) || point;
|
if (point.state === 'MISSING' && reconstruction) return reconstructionPoint(reconstruction, month, query.mode === 'annual', now) || point;
|
||||||
return point;
|
return point;
|
||||||
});
|
});
|
||||||
@@ -89,15 +91,18 @@ export class HistoryService {
|
|||||||
}
|
}
|
||||||
async details(user: AuthUser, params: Record<string, string>): Promise<HistoryDetails> {
|
async details(user: AuthUser, params: Record<string, string>): Promise<HistoryDetails> {
|
||||||
const now = this.clock();
|
const now = this.clock();
|
||||||
const month = params.month || '';
|
const month = params.day || params.month || '';
|
||||||
if (!/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) throw new HistoryInputError('月份无效');
|
if (params.day) {
|
||||||
|
try { if (params.day.length !== 10) throw new Error(); historyCutoff(params.day); }
|
||||||
|
catch { throw new HistoryInputError('日期无效'); }
|
||||||
|
} else if (!/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) throw new HistoryInputError('月份无效');
|
||||||
parseHistoryQuery({ ...params, year: month.slice(0, 4) }, now);
|
parseHistoryQuery({ ...params, year: month.slice(0, 4) }, now);
|
||||||
const category = params.category || 'all';
|
const category = params.category || 'all';
|
||||||
const page = Number(params.page || '1'), pageSize = Number(params.pageSize || '50');
|
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('明细筛选或分页参数无效');
|
if (!['all', 'operating', 'inventory'].includes(category) || !Number.isSafeInteger(page) || page < 1 || page > 100000 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100 || (params.search || '').length > 100 || (params.batchId || '').length > 36) throw new HistoryInputError('明细筛选或分页参数无效');
|
||||||
const archive = await this.archives([month]);
|
const archive = await this.archives([month], user.permissionLevel === 'full' && !params.subject?.trim());
|
||||||
const batch = archive.batches.find(b => b.month === month);
|
const batch = archive.batches.find(b => b.month === month);
|
||||||
const isCurrent = month === shanghaiMonth(now) && now < monthCutoff(month);
|
const isCurrent = month === (params.day ? shanghaiDay(now) : shanghaiMonth(now)) && now < historyCutoff(month);
|
||||||
if (!isCurrent && makePoint(month, now, batch).state === 'MISSING') {
|
if (!isCurrent && makePoint(month, now, batch).state === 'MISSING') {
|
||||||
const reconstruction = await this.reconstructionData(user, params.subject?.trim());
|
const reconstruction = await this.reconstructionData(user, params.subject?.trim());
|
||||||
const point = reconstruction && reconstructionPoint(reconstruction, month, false, now);
|
const point = reconstruction && reconstructionPoint(reconstruction, month, false, now);
|
||||||
@@ -121,6 +126,6 @@ export class HistoryService {
|
|||||||
return { point, counts: counts(scoped), total: filtered.length, page, pageSize,
|
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,
|
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,
|
disposition: v.disposition, operationStatus: v.operationStatus, vehicleStatus: v.vehicleStatus, subjectOrg: v.subjectOrg,
|
||||||
departmentName: v.departmentName, customerManager: v.customerManager, customerName: v.customerName, contractNo: v.contractNo })) };
|
departmentName: v.departmentName, customerManager: v.customerManager, customerName: v.customerName, contractNo: v.contractNo, reconstructionBasis: v.businessBasis })) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type
|
|||||||
|
|
||||||
export interface HistoryReader {
|
export interface HistoryReader {
|
||||||
settings(): Promise<HistorySettings | null>;
|
settings(): Promise<HistorySettings | null>;
|
||||||
batches(): Promise<HistoryBatch[]>;
|
batches(days?: readonly string[]): Promise<HistoryBatch[]>;
|
||||||
vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>>;
|
vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>>;
|
||||||
}
|
}
|
||||||
export type CaptureResult = 'captured' | 'already-complete' | 'locked' | 'outside-window';
|
export type CaptureResult = 'captured' | 'already-complete' | 'locked' | 'outside-window';
|
||||||
@@ -29,11 +29,11 @@ export class MysqlHistoryStore implements HistoryReader {
|
|||||||
const rows = await this.read(`SELECT ${dateColumn('daily_enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
|
const rows = await this.read(`SELECT ${dateColumn('daily_enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
|
||||||
return rows[0]?.enabledAt ? { enabledAt: rows[0].enabledAt } : null;
|
return rows[0]?.enabledAt ? { enabledAt: rows[0].enabledAt } : null;
|
||||||
}
|
}
|
||||||
async batches(): Promise<HistoryBatch[]> {
|
async batches(days?: readonly string[]): Promise<HistoryBatch[]> {
|
||||||
const rows = await this.read(`SELECT id, LEFT(period_key,7) AS month, status, source,
|
const rows = await this.read(`SELECT id, ${days ? 'period_key' : 'LEFT(period_key,7)'} AS month, status, source,
|
||||||
total_count,operating_count,inventory_count,
|
total_count,operating_count,inventory_count,
|
||||||
${dateColumn('scheduled_for')} AS scheduledFor, ${dateColumn('captured_at')} AS capturedAt
|
${dateColumn('scheduled_for')} AS scheduledFor, ${dateColumn('captured_at')} AS capturedAt
|
||||||
FROM bi_asset_history_batches WHERE definition_version=? AND (CHAR_LENGTH(period_key)=7 OR period_key=DATE_FORMAT(LAST_DAY(CONCAT(LEFT(period_key,7),'-01')), '%Y-%m-%d')) ORDER BY period_key`, [HISTORY_VERSION]);
|
FROM bi_asset_history_batches WHERE (definition_version=? OR (definition_version='asset-business-v1' AND source='业务回溯')) AND ${days ? 'period_key BETWEEN ? AND ? AND CHAR_LENGTH(period_key)=10' : "(CHAR_LENGTH(period_key)=7 OR period_key=DATE_FORMAT(LAST_DAY(CONCAT(LEFT(period_key,7),'-01')), '%Y-%m-%d'))"} ORDER BY (source='业务回溯') DESC, period_key`, days ? [HISTORY_VERSION, days[0], days[days.length - 1]] : [HISTORY_VERSION]);
|
||||||
const batches = rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt,
|
const batches = rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt,
|
||||||
counts: { total: row.total_count, operating: row.operating_count, inventory: row.inventory_count } }));
|
counts: { total: row.total_count, operating: row.operating_count, inventory: row.inventory_count } }));
|
||||||
const byMonth = new Map<string, HistoryBatch>();
|
const byMonth = new Map<string, HistoryBatch>();
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ export function mapStatus(operationStatus: string | null, vehicleStatus: string
|
|||||||
|
|
||||||
/** Operational category is independent from movement/delivery workflow status. */
|
/** Operational category is independent from movement/delivery workflow status. */
|
||||||
export function isOperatingVehicle(vehicle: Vehicle): boolean {
|
export function isOperatingVehicle(vehicle: Vehicle): boolean {
|
||||||
return ['1', '2', '租赁', '自营'].includes((vehicle.operationStatus || '').trim());
|
return ['1', '2', '租赁', '自营'].includes((vehicle.operationStatus || '').trim())
|
||||||
|
|| ('businessBasis' in vehicle && vehicle.operationStatus === '运营');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isInventoryVehicle(vehicle: Vehicle): boolean {
|
export function isInventoryVehicle(vehicle: Vehicle): boolean {
|
||||||
return ['3', '4', '可运营', '待运营', '在库'].includes((vehicle.operationStatus || '').trim());
|
return ['3', '4', '可运营', '待运营', '在库'].includes((vehicle.operationStatus || '').trim())
|
||||||
|
|| ('businessBasis' in vehicle && vehicle.operationStatus === '库存');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isInventoryAbnormal(vehicle: Vehicle): boolean {
|
export function isInventoryAbnormal(vehicle: Vehicle): boolean {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type HistoryMode = 'monthly' | 'annual';
|
export type HistoryMode = 'daily' | 'monthly' | 'annual';
|
||||||
export type HistoryState = 'LIVE' | 'ARCHIVED' | 'MISSING' | 'FUTURE' | 'CAPTURING' | 'FAILED' | 'RECONSTRUCTED';
|
export type HistoryState = 'LIVE' | 'ARCHIVED' | 'MISSING' | 'FUTURE' | 'CAPTURING' | 'FAILED' | 'RECONSTRUCTED';
|
||||||
export type HistoryCategory = 'all' | 'operating' | 'inventory';
|
export type HistoryCategory = 'all' | 'operating' | 'inventory';
|
||||||
export interface HistoryCounts { total: number; operating: number; inventory: number }
|
export interface HistoryCounts { total: number; operating: number; inventory: number }
|
||||||
@@ -9,7 +9,7 @@ export interface HistoryPoint {
|
|||||||
scheduledFor: string;
|
scheduledFor: string;
|
||||||
asOf: string | null;
|
asOf: string | null;
|
||||||
state: HistoryState;
|
state: HistoryState;
|
||||||
source: 'AUTO' | 'IMPORT' | 'LIVE' | 'RECONSTRUCTED' | null;
|
source: 'AUTO' | 'IMPORT' | '业务回溯' | 'LIVE' | 'RECONSTRUCTED' | null;
|
||||||
batchId: string | null;
|
batchId: string | null;
|
||||||
counts: HistoryCounts | null;
|
counts: HistoryCounts | null;
|
||||||
difference: HistoryCounts | null;
|
difference: HistoryCounts | null;
|
||||||
@@ -69,6 +69,9 @@ export function dayCutoff(day: string): Date {
|
|||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(date.getTime()) || shanghaiDay(date) !== day) throw new Error('日期格式无效');
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(date.getTime()) || shanghaiDay(date) !== day) throw new Error('日期格式无效');
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
export function historyCutoff(period: string): Date {
|
||||||
|
return period.length === 10 ? dayCutoff(period) : monthCutoff(period);
|
||||||
|
}
|
||||||
export function shiftDay(day: string, offset: number): string {
|
export function shiftDay(day: string, offset: number): string {
|
||||||
return shanghaiDay(new Date(dayCutoff(day).getTime() + offset * 86_400_000));
|
return shanghaiDay(new Date(dayCutoff(day).getTime() + offset * 86_400_000));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user