fix(access): expose master data maintenance queue
This commit is contained in:
@@ -132,6 +132,9 @@ func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessS
|
||||
case "degraded":
|
||||
result.DegradedVehicles++
|
||||
}
|
||||
if len(row.MasterDataIssues) > 0 {
|
||||
result.MasterDataIncompleteVehicles++
|
||||
}
|
||||
switch row.OnlineState {
|
||||
case "online":
|
||||
result.OnlineVehicles++
|
||||
@@ -502,7 +505,11 @@ func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(query.ConnectionState); value != "" && value != "all" {
|
||||
if value == "attention" {
|
||||
if value == "master_data" {
|
||||
if len(row.MasterDataIssues) == 0 {
|
||||
return false
|
||||
}
|
||||
} else if value == "attention" {
|
||||
if row.ConnectionState == "healthy" {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ func TestAccessSummaryUsesDynamicFreshnessAndDelay(t *testing.T) {
|
||||
if summary.HealthyVehicles != 1 || summary.IncompleteVehicles != 0 || summary.DegradedVehicles != 2 {
|
||||
t.Fatalf("connection-state counters must be mutually exclusive: %+v", summary)
|
||||
}
|
||||
if summary.MasterDataIncompleteVehicles != 1 {
|
||||
t.Fatalf("master-data maintenance must be counted independently from connection health: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
|
||||
@@ -50,6 +53,10 @@ func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
|
||||
if err != nil || attention.Total != 5 {
|
||||
t.Fatalf("attention filter should return every vehicle requiring attention: page=%+v err=%v", attention, err)
|
||||
}
|
||||
masterData, err := service.AccessVehicles(context.Background(), AccessQuery{ConnectionState: "master_data", Limit: 20})
|
||||
if err != nil || masterData.Total != 1 || len(masterData.Items[0].MasterDataIssues) == 0 {
|
||||
t.Fatalf("master-data queue should include issues regardless of connection state: page=%+v err=%v", masterData, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehiclesSupportsModelProviderAndTimeFilters(t *testing.T) {
|
||||
|
||||
@@ -520,22 +520,23 @@ type AccessDistribution struct {
|
||||
}
|
||||
|
||||
type AccessSummary struct {
|
||||
TotalVehicles int `json:"totalVehicles"`
|
||||
OnlineVehicles int `json:"onlineVehicles"`
|
||||
OfflineVehicles int `json:"offlineVehicles"`
|
||||
LongOfflineVehicles int `json:"longOfflineVehicles"`
|
||||
NeverReported int `json:"neverReported"`
|
||||
UnknownVehicles int `json:"unknownVehicles"`
|
||||
DelayAbnormal int `json:"delayAbnormal"`
|
||||
ReportedToday int `json:"reportedToday"`
|
||||
HealthyVehicles int `json:"healthyVehicles"`
|
||||
IncompleteVehicles int `json:"incompleteVehicles"`
|
||||
DegradedVehicles int `json:"degradedVehicles"`
|
||||
OnlineRate float64 `json:"onlineRate"`
|
||||
Protocols []AccessDistribution `json:"protocols"`
|
||||
OEMs []AccessDistribution `json:"oems"`
|
||||
AsOf string `json:"asOf"`
|
||||
ThresholdVersion int `json:"thresholdVersion"`
|
||||
TotalVehicles int `json:"totalVehicles"`
|
||||
OnlineVehicles int `json:"onlineVehicles"`
|
||||
OfflineVehicles int `json:"offlineVehicles"`
|
||||
LongOfflineVehicles int `json:"longOfflineVehicles"`
|
||||
NeverReported int `json:"neverReported"`
|
||||
UnknownVehicles int `json:"unknownVehicles"`
|
||||
DelayAbnormal int `json:"delayAbnormal"`
|
||||
ReportedToday int `json:"reportedToday"`
|
||||
HealthyVehicles int `json:"healthyVehicles"`
|
||||
IncompleteVehicles int `json:"incompleteVehicles"`
|
||||
DegradedVehicles int `json:"degradedVehicles"`
|
||||
MasterDataIncompleteVehicles int `json:"masterDataIncompleteVehicles"`
|
||||
OnlineRate float64 `json:"onlineRate"`
|
||||
Protocols []AccessDistribution `json:"protocols"`
|
||||
OEMs []AccessDistribution `json:"oems"`
|
||||
AsOf string `json:"asOf"`
|
||||
ThresholdVersion int `json:"thresholdVersion"`
|
||||
}
|
||||
|
||||
type AccessProtocolThreshold struct {
|
||||
|
||||
@@ -300,7 +300,7 @@ export interface AccessDistribution { name: string; total: number; online: numbe
|
||||
export interface AccessSummary {
|
||||
totalVehicles: number; onlineVehicles: number; offlineVehicles: number; longOfflineVehicles: number;
|
||||
neverReported: number; unknownVehicles: number; delayAbnormal: number; reportedToday: number; onlineRate: number;
|
||||
healthyVehicles: number; incompleteVehicles: number; degradedVehicles: number;
|
||||
healthyVehicles: number; incompleteVehicles: number; degradedVehicles: number; masterDataIncompleteVehicles: number;
|
||||
protocols: AccessDistribution[]; oems: AccessDistribution[]; asOf: string; thresholdVersion: number;
|
||||
}
|
||||
export interface AccessProtocolThreshold { protocol: string; thresholdSec: number; }
|
||||
|
||||
@@ -32,7 +32,7 @@ function accessRow(vin: string, plate: string): AccessVehicleRow {
|
||||
}
|
||||
|
||||
function prepareBaseData() {
|
||||
mocks.accessSummary.mockResolvedValue({ totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, longOfflineVehicles: 0, neverReported: 0, unknownVehicles: 0, delayAbnormal: 0, reportedToday: 2, onlineRate: 100, healthyVehicles: 2, incompleteVehicles: 0, degradedVehicles: 0, protocols: [], oems: [{ name: '测试品牌', total: 2, online: 2, onlineRate: 100 }], asOf: '2026-07-16T04:00:00Z', thresholdVersion: 1 });
|
||||
mocks.accessSummary.mockResolvedValue({ totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, longOfflineVehicles: 0, neverReported: 0, unknownVehicles: 0, delayAbnormal: 0, reportedToday: 2, onlineRate: 100, healthyVehicles: 2, incompleteVehicles: 0, degradedVehicles: 0, masterDataIncompleteVehicles: 0, protocols: [], oems: [{ name: '测试品牌', total: 2, online: 2, onlineRate: 100 }], asOf: '2026-07-16T04:00:00Z', thresholdVersion: 1 });
|
||||
mocks.accessUnresolvedIdentities.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
mocks.accessThresholds.mockResolvedValue({ version: 1, defaultThresholdSec: 300, delayThresholdSec: 60, longOfflineSec: 86_400, protocols: [], updatedBy: 'test', updatedAt: '2026-07-16T04:00:00Z', audit: [] });
|
||||
}
|
||||
|
||||
@@ -120,10 +120,10 @@ export default function AccessPage() {
|
||||
return <div className="v2-access-page v2-access-page-v3">
|
||||
<header className="v2-access-heading"><div><h2>车辆接入管理</h2><p>只展示车辆真实存在的来源、在线健康和待维护资料;未接业务系统前不推断应接协议</p></div><div><span>数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void refresh()}><IconRefresh />刷新</button></div></header>
|
||||
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b>筛选条件</b><small>{Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length} 项` : '全部主车辆'}</small></span><em>{filtersCollapsed ? '展开' : '收起'}</em></button>
|
||||
<form className={`v2-access-filter-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label className="is-search"><span>车辆</span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span>接入状态</span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value="">全部状态</option><option value="attention">需关注</option><option value="healthy">已接来源正常</option><option value="incomplete">资料待维护</option><option value="degraded">部分来源异常</option><option value="offline">已接来源离线</option><option value="not_connected">尚无来源</option></select></label><label><span>真实协议</span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value="">全部协议</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>车辆品牌</span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value="">全部品牌</option>{summary?.oems.map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit">查询</button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}>重置</button></form>
|
||||
<form className={`v2-access-filter-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label className="is-search"><span>车辆</span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span>接入状态</span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value="">全部状态</option><option value="attention">需关注</option><option value="healthy">已接来源正常</option><option value="master_data">资料待维护</option><option value="degraded">部分来源异常</option><option value="offline">已接来源离线</option><option value="not_connected">尚无来源</option></select></label><label><span>真实协议</span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value="">全部协议</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>车辆品牌</span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value="">全部品牌</option>{summary?.oems.map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit">查询</button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}>重置</button></form>
|
||||
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
|
||||
<section className="v2-access-kpis-v3">{[
|
||||
['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['需关注', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['资料待维护', summary?.incompleteVehicles ?? 0, 'incomplete', 'incomplete'], ['尚无来源', summary?.neverReported ?? 0, 'never', 'not_connected']
|
||||
['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['需关注', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['资料待维护', summary?.masterDataIncompleteVehicles ?? 0, 'incomplete', 'master_data'], ['尚无来源', summary?.neverReported ?? 0, 'never', 'not_connected']
|
||||
].map(([label, value, tone, connectionState]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em>车辆主档</em> : null}</button>)}</section>
|
||||
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
|
||||
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong>车辆真实接入来源</strong><span>缺席协议只表示“当前未发现”,不会被推断成应接缺失;时间为各来源最后接收时间</span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload />导出当前页</button></div></header><div className="v2-access-table-scroll-v3">{mobileLayout ? <div className="v2-access-mobile-list">{rows.map((row) => <button type="button" key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><div>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</div></button>)}</div> : <table><thead><tr><th>车辆</th><th>品牌 / 车型</th><th>真实来源</th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th>综合状态</th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.actualProtocols.length} 个</b><span>{row.actualProtocols.join(' / ') || '尚无来源'}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>}{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i />正在更新车辆接入状态…</div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty">当前筛选条件没有车辆</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select aria-label="每页数量" value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer></section>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
|
||||
|
||||
Reference in New Issue
Block a user