feat(platform): filter quality issues by type
This commit is contained in:
@@ -647,6 +647,40 @@ func TestBuildQualityIssueWhereUsesMatchingArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildQualityIssueWhereFiltersIssueType(t *testing.T) {
|
||||||
|
fromSQL, args := buildQualityIssueWhere(url.Values{"issueType": {"NO_SOURCE"}})
|
||||||
|
if !strings.Contains(fromSQL, "q.issue_type = ?") {
|
||||||
|
t.Fatalf("quality issue SQL should filter issue type: %s", fromSQL)
|
||||||
|
}
|
||||||
|
if len(args) != 1 || args[0] != "NO_SOURCE" {
|
||||||
|
t.Fatalf("args = %#v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerQualityIssuesFiltersIssueType(t *testing.T) {
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/quality/issues?issueType=NO_SOURCE&limit=20", nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data Page[QualityIssueRow] `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body.Data.Total == 0 {
|
||||||
|
t.Fatalf("expected at least one NO_SOURCE issue")
|
||||||
|
}
|
||||||
|
for _, item := range body.Data.Items {
|
||||||
|
if item.IssueType != "NO_SOURCE" {
|
||||||
|
t.Fatalf("expected only NO_SOURCE issues, got %+v", body.Data.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) {
|
func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) {
|
||||||
handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{RequestTimeoutMs: 1500}))
|
handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{RequestTimeoutMs: 1500}))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[Qua
|
|||||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||||
rows = keep(rows, func(row QualityIssueRow) bool { return row.Protocol == protocol })
|
rows = keep(rows, func(row QualityIssueRow) bool { return row.Protocol == protocol })
|
||||||
}
|
}
|
||||||
|
if issueType := strings.TrimSpace(query.Get("issueType")); issueType != "" {
|
||||||
|
rows = keep(rows, func(row QualityIssueRow) bool { return row.IssueType == issueType })
|
||||||
|
}
|
||||||
return page(rows, query), nil
|
return page(rows, query), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -713,6 +713,10 @@ func buildQualityIssueWhere(query url.Values) (string, []any) {
|
|||||||
where = append(where, "q.protocol = ?")
|
where = append(where, "q.protocol = ?")
|
||||||
args = append(args, protocol)
|
args = append(args, protocol)
|
||||||
}
|
}
|
||||||
|
if issueType := strings.TrimSpace(query.Get("issueType")); issueType != "" {
|
||||||
|
where = append(where, "q.issue_type = ?")
|
||||||
|
args = append(args, issueType)
|
||||||
|
}
|
||||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||||
where = append(where, "(q.vin LIKE ? OR q.plate LIKE ? OR q.phone LIKE ? OR q.source_endpoint LIKE ?)")
|
where = append(where, "(q.vin LIKE ? OR q.plate LIKE ? OR q.phone LIKE ? OR q.source_endpoint LIKE ?)")
|
||||||
like := "%" + keyword + "%"
|
like := "%" + keyword + "%"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { qualityIssueLabel, qualityProtocolLabel, qualityProtocolOptions } from './qualityIssue';
|
import { qualityIssueLabel, qualityIssueOptions, qualityProtocolLabel, qualityProtocolOptions } from './qualityIssue';
|
||||||
|
|
||||||
describe('qualityIssueLabel', () => {
|
describe('qualityIssueLabel', () => {
|
||||||
test('maps no-source issues to a vehicle-service label', () => {
|
test('maps no-source issues to a vehicle-service label', () => {
|
||||||
@@ -27,3 +27,10 @@ describe('qualityProtocolOptions', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('qualityIssueOptions', () => {
|
||||||
|
test('includes issue types used by governance filters', () => {
|
||||||
|
expect(qualityIssueOptions.map((item) => item.value)).toContain('NO_SOURCE');
|
||||||
|
expect(qualityIssueOptions.map((item) => item.value)).toContain('VIN_MISSING');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export const qualityProtocolOptions = [
|
|||||||
{ value: 'YUTONG_MQTT', label: protocolLabels.YUTONG_MQTT }
|
{ value: 'YUTONG_MQTT', label: protocolLabels.YUTONG_MQTT }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const qualityIssueOptions = [
|
||||||
|
{ value: 'NO_SOURCE', label: issueLabels.NO_SOURCE },
|
||||||
|
{ value: 'VIN_MISSING', label: issueLabels.VIN_MISSING },
|
||||||
|
{ value: 'LINK_GAP', label: issueLabels.LINK_GAP },
|
||||||
|
{ value: 'FIELD_MISSING', label: issueLabels.FIELD_MISSING }
|
||||||
|
];
|
||||||
|
|
||||||
export function qualityIssueLabel(issueType: string) {
|
export function qualityIssueLabel(issueType: string) {
|
||||||
return issueLabels[issueType] ?? issueType;
|
return issueLabels[issueType] ?? issueType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types';
|
import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { qualityIssueLabel, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue';
|
import { qualityIssueLabel, qualityIssueOptions, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue';
|
||||||
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
||||||
|
|
||||||
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||||
@@ -39,6 +39,7 @@ function qualityParams(values: Record<string, string>) {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (values?.keyword) params.set('keyword', values.keyword);
|
if (values?.keyword) params.set('keyword', values.keyword);
|
||||||
if (values?.protocol) params.set('protocol', values.protocol);
|
if (values?.protocol) params.set('protocol', values.protocol);
|
||||||
|
if (values?.issueType) params.set('issueType', values.issueType);
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ export function Quality({
|
|||||||
const [loadingHealth, setLoadingHealth] = useState(true);
|
const [loadingHealth, setLoadingHealth] = useState(true);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||||||
|
const primaryIssueType = summary.issueTypes[0]?.name;
|
||||||
|
|
||||||
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||||||
setLoadingIssues(true);
|
setLoadingIssues(true);
|
||||||
@@ -111,6 +113,19 @@ export function Quality({
|
|||||||
loadHealth();
|
loadHealth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const applyFilters = (nextFilters: Record<string, string>) => {
|
||||||
|
setFilters(nextFilters);
|
||||||
|
loadSummary(nextFilters);
|
||||||
|
loadIssues(nextFilters, 1, pagination.pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drillPrimaryIssue = () => {
|
||||||
|
if (!primaryIssueType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyFilters({ ...filters, issueType: primaryIssueType });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
<PageHeader title="质量治理" description="围绕车辆服务排查断链、VIN 缺失、字段缺失和链路健康" />
|
<PageHeader title="质量治理" description="围绕车辆服务排查断链、VIN 缺失、字段缺失和链路健康" />
|
||||||
@@ -121,12 +136,27 @@ export function Quality({
|
|||||||
{ label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` },
|
{ label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` },
|
||||||
{
|
{
|
||||||
label: '主要问题',
|
label: '主要问题',
|
||||||
value: summary.issueTypes.length > 0 ? `${qualityIssueLabel(summary.issueTypes[0].name)} ${summary.issueTypes[0].count}` : '-'
|
value: primaryIssueType ? `${qualityIssueLabel(primaryIssueType)} ${summary.issueTypes[0].count}` : '-',
|
||||||
|
onClick: primaryIssueType ? drillPrimaryIssue : undefined
|
||||||
}
|
}
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<Card key={item.label} bordered loading={loadingSummary}>
|
<Card key={item.label} bordered loading={loadingSummary}>
|
||||||
|
{item.onClick ? (
|
||||||
|
<button
|
||||||
|
className="vp-result-summary-button"
|
||||||
|
type="button"
|
||||||
|
aria-label={`${item.label} ${item.value}`}
|
||||||
|
onClick={item.onClick}
|
||||||
|
>
|
||||||
<div className="vp-kpi-value">{item.value}</div>
|
<div className="vp-kpi-value">{item.value}</div>
|
||||||
<div className="vp-kpi-label">{item.label}</div>
|
<div className="vp-kpi-label">{item.label}</div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="vp-kpi-value">{item.value}</div>
|
||||||
|
<div className="vp-kpi-label">{item.label}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,11 +171,9 @@ export function Quality({
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Card bordered title="质量问题" style={{ marginTop: 16 }}>
|
<Card bordered title="质量问题" style={{ marginTop: 16 }}>
|
||||||
<Form layout="horizontal" onSubmit={(values) => {
|
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||||||
const nextFilters = values as Record<string, string>;
|
const nextFilters = values as Record<string, string>;
|
||||||
setFilters(nextFilters);
|
applyFilters(nextFilters);
|
||||||
loadSummary(nextFilters);
|
|
||||||
loadIssues(nextFilters, 1, pagination.pageSize);
|
|
||||||
}} style={{ marginBottom: 12 }}>
|
}} style={{ marginBottom: 12 }}>
|
||||||
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / 来源地址" style={{ width: 260 }} />
|
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / 来源地址" style={{ width: 260 }} />
|
||||||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 160 }}>
|
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 160 }}>
|
||||||
@@ -153,12 +181,15 @@ export function Quality({
|
|||||||
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
||||||
))}
|
))}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
|
<Form.Select field="issueType" label="问题类型" placeholder="全部问题" style={{ width: 160 }}>
|
||||||
|
{qualityIssueOptions.map((item) => (
|
||||||
|
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
<Space>
|
<Space>
|
||||||
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
||||||
<Button onClick={() => {
|
<Button onClick={() => {
|
||||||
setFilters({});
|
applyFilters({});
|
||||||
loadSummary({});
|
|
||||||
loadIssues({}, 1, pagination.pageSize);
|
|
||||||
}}>重置</Button>
|
}}>重置</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -729,6 +729,72 @@ test('renders quality issues as vehicle-service governance labels', async () =>
|
|||||||
expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('drills into quality issues by issue type', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/quality');
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/ops/health')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/quality/summary')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
issueVehicleCount: 1,
|
||||||
|
issueRecordCount: 1,
|
||||||
|
errorCount: 0,
|
||||||
|
warningCount: 1,
|
||||||
|
protocols: [{ name: 'VEHICLE_SERVICE', count: 1 }],
|
||||||
|
issueTypes: [{ name: 'NO_SOURCE', count: 1 }]
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/quality/issues')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [],
|
||||||
|
total: 1,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 20, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /主要问题 暂无数据来源 1/ }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?issueType=NO_SOURCE&limit=20&offset=0'), undefined);
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?issueType=NO_SOURCE'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('opens vehicle detail from shareable hash', async () => {
|
test('opens vehicle detail from shareable hash', async () => {
|
||||||
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user