From 1674f052d06de9b754a08299c1743a78dbb78ff7 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Fri, 16 Oct 2026 18:15:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(assets):=20=E5=AE=8C=E5=96=84=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E6=8F=90=E6=8A=A5=E6=9D=A5=E6=BA=90=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E7=82=B9=E8=B7=9F=E8=BF=9B=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/asset-fault-management.md | 8 +- src/modules/assets/faults/DashboardPanels.tsx | 4 +- src/modules/assets/faults/FaultManagement.tsx | 20 ++-- src/modules/assets/faults/FaultOverview.tsx | 6 +- src/modules/assets/faults/dashboard-export.ts | 2 +- src/modules/assets/faults/dashboard.css | 32 ++++--- .../routes/asset-faults/dashboard.test.ts | 94 +++++++++++++++++++ src/server/routes/asset-faults/dashboard.ts | 20 ++-- src/server/routes/asset-faults/model.ts | 11 ++- src/server/routes/asset-faults/repository.ts | 3 +- src/shared/assets/faults.ts | 11 ++- 11 files changed, 166 insertions(+), 45 deletions(-) diff --git a/docs/asset-fault-management.md b/docs/asset-fault-management.md index 3c075b9..8d9d7e0 100644 --- a/docs/asset-fault-management.md +++ b/docs/asset-fault-management.md @@ -11,7 +11,7 @@ | 当前在办 | 全部待处理、处理中故障,不限制提报日期;按最新确认,在办 = 待处理 + 处理中 | | 累计台账 | 全部有效故障台账,包括归档、未闭环和其他状态 | -「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“其中逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。 +「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。 - 待处理/处理中:按 OneOS 标准化任务状态分别统计,兼容待处置、未处理、处置中等历史值。 - 逾期:待处理或处理中,且保存的 `deadline_at` 严格早于本次数据时间。按北京时间解释截止时间,挂起和已归档不计入;无截止时间不自行以提报时间或固定 30 天推算。与 OneOS `selectSupervisorRows.overdueCount` 汇总口径一致,存储的截止时间包含业务端 SLA 配置及挂起顺延结果。 @@ -84,18 +84,18 @@ 故障管理按最新确认保留「管理总览 / 多维分析 / 故障明细」三个页签。管理总览与处理效率合并,恢复原有页面及统计:今日新增、本日解决、当前在办(待处理 + 处理中,逾期为子集)、累计台账,沿用 `/overview` 接口;下方直接展示原有故障处理趋势与故障运维周报。趋势默认近30天,周报默认周六至周五,分别选择日期、导出和下钻。管理总览不受其他页签的业务筛选影响。多维分析、故障明细保留新增分析能力及统一日期、区域、客户、品牌车型查询;更多筛选包含城市、业务部门、运维处置人、故障程度、故障部位、库存状态,点击查询生效。 -- 期间新增故障:所选提报日期内的有效故障。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。 +- 期间新增故障:所选提报日期内的有效故障。卡片增加“运维提报”和“AI提报”数量及下钻;两者之和等于期间新增,沿用日期、权限及业务筛选。来源优先读取 `report_method`:运维上报显示“运维提报”,客户上报显示“AI提报”;旧数据未标明有效上报方式时,有真实 `source_work_order_id` 关联归客户上报,否则归运维上报,与 OneOS 历史展示逻辑一致。该分类不使用 `fault_source` 故障来源字典,也不推断所有客户上报均由 AI 自动创建。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。 - 受影响车辆:同批故障按关联车辆 ID 去重,缺 ID 时使用故障车牌;两者都缺失的不计车辆数,单列数据完整性提示。车辆跨组可能重复,不直接累加。 - 当前在办:已应用业务筛选下,所有日期的待处理 + 处理中。逾期为其子集;最长逾期按截止时间到数据时间的天数向上取整。 - 区域、等级、客户、车型分布和车型×部位矩阵:按本期提报队列统计,闭环状态截至更新时间。一单多部位分别计入单元格,车型合计按单去重。 - 客户影响表:本期影响车辆去重;同一客户的当前在办不限提报日期。库存空客户单列库存车。仅展示本期存在故障的客户组。 -- 重点跟进:当前在办中优先展示逾期,同优先级按提报时间倒序。完整明细可切换本期、在办、逾期及累计台账,搜索车牌、单号、描述或运维处置人。 +- 重点跟进:只展示 L1-特急且未闭环(状态非已归档,含挂起等)的故障,沿用权限及业务筛选,不限制提报日期。逾期优先,同优先级按提报时间倒序,最多展示5单;统计标签使用同一 L1 未闭环范围,没有符合条件的故障时显示空态。“查看全部”始终可用,进入当前权限内的全部故障台账,清除业务筛选和搜索条件,不限等级、状态及提报日期。完整明细可切换本期、在办、逾期及累计台账,搜索车牌、单号、描述或运维处置人。 新增只读字段:`fault_level`、`fault_categories`、`solve_process`。等级映射经 OneOS 字典核对:1=L1-特急、2=L2-紧急、3=L3-一般、4=L4-提示;缺失等级/部位独立显示未标注。处置记录使用 `solve_process`,不将 AI 流转字段 `process_status` 当作业务处理进度。没有同期服务车辆规模、实际停运和修复时长依据,发生频率禁用并提示缺少分母,停运和修复时长提示待补数据。 新增接口: - `GET /api/asset-faults/dashboard?start=YYYY-MM-DD&end=YYYY-MM-DD`:授权范围内汇总、筛选项、分布、交叉矩阵、重点跟进和完整性信息。 -- `GET /api/asset-faults/dashboard-analysis`:同样日期和业务筛选;`scope=cohort|open|waiting|processing|overdue|ledger|reported|closed`。cohort 为提报日期队列,reported/closed 为北京时间今日,其他为全部日期当前状态;可附 `id` 查看单条记录。 +- `GET /api/asset-faults/dashboard-analysis`:同样日期和业务筛选;`scope=focus|cohort|open|waiting|processing|overdue|ledger|reported|closed`。focus 为全部提报日期的 L1 未闭环故障;cohort 为提报日期队列,reported/closed 为北京时间今日,其他为全部日期当前状态;可附 `id` 查看单条记录。 - 趋势、周报及其下钻接口同时支持上述业务筛选。所有接口先执行权限筛选,筛选项也仅来自授权记录,保持 no-store,不写业务数据库。 总览导出新增 6 张 Excel 工作表:管理总览、区域分布、故障程度、客户影响、车型分布、车型与部位。导出已加载统计快照,包含日期、业务筛选、数据时间、冻结表头、交替底色和数字格式。趋势、周报的既有导出保留,并增加业务筛选说明。 diff --git a/src/modules/assets/faults/DashboardPanels.tsx b/src/modules/assets/faults/DashboardPanels.tsx index 91f826b..6c80ac8 100644 --- a/src/modules/assets/faults/DashboardPanels.tsx +++ b/src/modules/assets/faults/DashboardPanels.tsx @@ -37,6 +37,6 @@ export function DashboardPanels({data, drill}:{data:FaultDashboardResponse;drill ; } -export function DashboardTicketTable({rows,updatedAt,drill}:{rows:FaultAnalysisRecord[];updatedAt:string;drill:DashboardDrill}) { - return
{rows.map(row=>{const overdue=['待处理','处理中'].includes(row.status)&&row.deadlineAt&&Date.parse(`${row.deadlineAt.replace(' ','T')}+08:00`)})}
车辆 / 故障单号运维处置人区域 / 客户等级当前状态处置记录 / 故障描述提报时间操作
{row.plate}{row.code}{row.handlers.map(h=>h.label).join('、')}{row.cell.region}{row.customerLabel}{row.level||'未标注'}{row.status==='已归档'?'已闭环':row.status}{overdue&&已逾期}{row.progress||row.description||'暂无处置记录'}{row.reportedAt||'未记录'}
{!rows.length&&

当前条件下暂无故障。

}
; +export function DashboardTicketTable({rows,updatedAt,drill,emptyMessage='当前条件下暂无故障。'}:{rows:FaultAnalysisRecord[];updatedAt:string;drill:DashboardDrill;emptyMessage?:string}) { + return
{rows.map(row=>{const overdue=['待处理','处理中'].includes(row.status)&&row.deadlineAt&&Date.parse(`${row.deadlineAt.replace(' ','T')}+08:00`)})}
车辆 / 故障单号运维处置人区域 / 客户等级当前状态处置记录 / 故障描述提报时间操作
{row.plate}{row.code}{row.handlers.map(h=>h.label).join('、')}{row.cell.region}{row.customerLabel}{row.level||'未标注'}{row.status==='已归档'?'已闭环':row.status}{overdue&&已逾期}{row.progress||row.description||'暂无处置记录'}{row.reportedAt||'未记录'}
{!rows.length&&

{emptyMessage}

}
; } diff --git a/src/modules/assets/faults/FaultManagement.tsx b/src/modules/assets/faults/FaultManagement.tsx index 4f0427d..528a362 100644 --- a/src/modules/assets/faults/FaultManagement.tsx +++ b/src/modules/assets/faults/FaultManagement.tsx @@ -24,13 +24,14 @@ export default function FaultManagement(){ const query=new URLSearchParams({start:applied.start,end:applied.end,...applied.filters}).toString(); useEffect(()=>{if(tab==='管理总览')return;const c=new AbortController();setLoading(true);setError('');fetchJson(`/api/asset-faults/dashboard?${query}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e instanceof Error?e.message:'加载失败');}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[query,revision,tab]); useEffect(()=>{if(tab!=='故障明细')return;const c=new AbortController();setDetailData(null);setDetailError('');setDetailLoading(true);setLimit(50);fetchJson(`/api/asset-faults/dashboard-analysis?${query}&scope=${detailScope}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setDetailData(d);}).catch(()=>{if(!c.signal.aborted)setDetailError('故障明细加载失败,请重试。');}).finally(()=>{if(!c.signal.aborted)setDetailLoading(false);});return()=>c.abort();},[tab,query,detailScope,revision]); - const drill:DashboardDrill=(title,scope='cohort',extra={})=>{if(loading||error||!data)return;const params=new URLSearchParams({start:data.start,end:data.end,...applied.filters,...extra,scope});setSource({url:`/api/asset-faults/dashboard-analysis?${params}`,title,periodLabel:scope==='cohort'?`${data.start} 至 ${data.end} · 按提报日期`:scope==='reported'||scope==='closed'?'北京时间今日':'全部提报日期 · 当前状态',defaultDimensions:extra.id?[]:['handler','faultStatus'],initialDetails:!!extra.id,definition:scope==='cohort'?'按提报日期筛选同批故障,已归档为已闭环,其余状态为未闭环。':scope==='overdue'?'逾期仅包含待处理、处理中且处置截止时间早于数据时间的故障;挂起、未设置截止时间不计入。':scope==='reported'?'按北京时间今日提报时间统计。':scope==='closed'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});}; + const drill:DashboardDrill=(title,scope='cohort',extra={})=>{if(loading||error||!data)return;const params=new URLSearchParams({start:data.start,end:data.end,...applied.filters,...extra,scope});setSource({url:`/api/asset-faults/dashboard-analysis?${params}`,title,periodLabel:scope==='cohort'?`${data.start} 至 ${data.end} · 按提报日期`:scope==='reported'||scope==='closed'?'北京时间今日':'全部提报日期 · 当前状态',defaultDimensions:extra.id?[]:['handler','faultStatus'],initialDetails:!!extra.id,definition:scope==='focus'?'仅包含 L1-特急且未归档的故障(含待处理、处理中、挂起等状态),不限制提报日期。':scope==='cohort'?'按提报日期筛选同批故障,已归档为已闭环,其余状态为未闭环。':scope==='overdue'?'逾期仅包含待处理、处理中且处置截止时间早于数据时间的故障;挂起、未设置截止时间不计入。':scope==='reported'?'按北京时间今日提报时间统计。':scope==='closed'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});}; const t=data?.totals; const detailRows=(detailData?.rows||[]).filter(r=>`${r.code} ${r.plate} ${r.description} ${r.handlers.map(h=>h.label).join(' ')}`.toLowerCase().includes(search.trim().toLowerCase())).sort((a,b)=>(b.reportedAt||'').localeCompare(a.reportedAt||'')||b.id.localeCompare(a.id,'en',{numeric:true})); const dirty=range.start!==applied.start||range.end!==applied.end||JSON.stringify(filters)!==JSON.stringify(applied.filters); const count=(n?:number)=>loading?'—':n?.toLocaleString('zh-CN')??'—'; const select=(key:FaultDashboardDimension)=>; const showAllOpen=()=>{setDetailScope('open');setTab('故障明细');setSearch('');}; + const showAllFaults=()=>{setFilters({});setApplied({...range,filters:{}});setDetailScope('ledger');setTab('故障明细');setSearch('');}; const navigation=; if(tab==='管理总览')return ; return
@@ -41,12 +42,11 @@ export default function FaultManagement(){ {error&&
{error}
} {!data&&loading?
正在汇总故障分析…
:data&&
{tab==='多维分析'&&<> -
-

对比上期 0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}上期 {t?.previous} 单

+
+

对比上期 0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}上期 {t?.previous} 单

+

涉及 {t?.customers} 家已关联客户按车辆去重

+
-

最长逾期 {t?.longestOverdueDays} 天在办子集

-

全部有效故障台账不限提报日期

+

最长逾期 {t?.longestOverdueDays} 天在办子集

实时统计
{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}按数量描述,不代表故障发生率。
@@ -55,12 +55,12 @@ export default function FaultManagement(){

重点故障跟进

- 当前在办 {t?.open} - 其中逾期 {t?.overdue} - + L1 未闭环 {data.focusTotals.total} + 逾期 {data.focusTotals.overdue} +
- -

沿用当前业务筛选,不限制提报日期;优先展示逾期故障,同优先级按提报时间倒序,最多展示 5 单。完整明细见“查看全部”。

+ +

仅展示 L1-特急且未闭环(未归档)的故障;沿用当前业务筛选,不限制提报日期。逾期优先,同优先级按提报时间倒序,最多展示 5 单。“查看全部”进入当前权限内的全部故障台账,并清除业务筛选和搜索条件,不限等级、状态及提报日期。

} {tab==='故障明细'&&

故障明细

按提报时间倒序 · {detailRows.length} 单
{([['cohort','本期提报'],['open','当前在办'],['overdue','逾期故障'],['ledger','累计台账']] as const).map(([key,title])=>)}
{detailLoading?

正在加载故障明细…

:detailError?

{detailError}

:<>{detailRows.length>limit&&}}
} diff --git a/src/modules/assets/faults/FaultOverview.tsx b/src/modules/assets/faults/FaultOverview.tsx index 617a435..92a080e 100644 --- a/src/modules/assets/faults/FaultOverview.tsx +++ b/src/modules/assets/faults/FaultOverview.tsx @@ -46,8 +46,8 @@ export function FaultOverview({ navigation }: { navigation: ReactNode }) {

{FAULT_DEFINITIONS[key]}

查看分析与明细 → {key === 'open' && <> -
{(['waiting', 'processing'] as const).map((part, index) => @@ -60,7 +60,7 @@ export function FaultOverview({ navigation }: { navigation: ReactNode }) { } ; })}
-

当前在办 = 待处理 + 处理中,其中逾期不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。

+

当前在办 = 待处理 + 处理中,逾期为在办子集,不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。

diff --git a/src/modules/assets/faults/dashboard-export.ts b/src/modules/assets/faults/dashboard-export.ts index 82291b0..3931747 100644 --- a/src/modules/assets/faults/dashboard-export.ts +++ b/src/modules/assets/faults/dashboard-export.ts @@ -2,7 +2,7 @@ import type { FaultDashboardResponse } from '../../../shared/assets/faults'; export async function downloadDashboard(data:FaultDashboardResponse,filters:string) { const {default:ExcelJS}=await import('exceljs');const book=new ExcelJS.Workbook();book.creator='羚牛氢能 BI'; const tables: {name:string;headers:string[];rows:(string|number)[][]}[]=[ - {name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['受影响车辆',data.totals.vehicles,'本期故障车辆去重'],['当前在办',data.totals.open,'全部待处理 + 处理中'],['其中逾期',data.totals.overdue,'当前在办中超过处置截止时间'],['待处理',data.totals.waiting,'全部待处理'],['处理中',data.totals.processing,'全部处理中'],['今日新增',data.totals.todayReported,'北京时间今日提报'],['本日解决',data.totals.todayClosed,'北京时间今日解决'],['累计台账',data.totals.ledger,'当前筛选范围的全部有效故障']]}, + {name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['运维提报',data.totals.operationsReported,'本期新增故障中的运维上报'],['AI提报',data.totals.customerReported,'本期新增故障中的客户上报'],['受影响车辆',data.totals.vehicles,'本期故障车辆去重'],['当前在办',data.totals.open,'全部待处理 + 处理中'],['逾期',data.totals.overdue,'当前在办中超过处置截止时间'],['待处理',data.totals.waiting,'全部待处理'],['处理中',data.totals.processing,'全部处理中'],['今日新增',data.totals.todayReported,'北京时间今日提报'],['本日解决',data.totals.todayClosed,'北京时间今日解决'],['累计台账',data.totals.ledger,'当前筛选范围的全部有效故障']]}, ...Object.entries(data.distributions).map(([key,rows])=>({name:({region:'区域分布',level:'故障程度',customer:'客户影响',model:'车型分布'} as Record)[key],headers:['分组','本期故障','已闭环','未闭环','影响车辆','当前在办'],rows:rows.map(r=>[r.label,r.reported,r.closed,r.unclosed,r.vehicles,r.open])})), {name:'车型与部位',headers:['品牌车型','故障部位','故障数量'],rows:data.matrix.cells.filter(c=>c.count).map(c=>[c.model,c.category,c.count])}, ]; diff --git a/src/modules/assets/faults/dashboard.css b/src/modules/assets/faults/dashboard.css index 5a7ef91..e8adff7 100644 --- a/src/modules/assets/faults/dashboard.css +++ b/src/modules/assets/faults/dashboard.css @@ -107,19 +107,27 @@ .fault-overview-page .fault-trend-heading h2 { font-size:13px; } } .app-shell-content:has(>.fault-overview-page)>.fixed.top-0 { display:none; } -.fd-kpis-five { grid-template-columns:repeat(5,minmax(0,1fr)); } -.fd-kpis-five>.fd-kpi { display:flex; flex-direction:column; } -.fd-kpis-five .fd-kpi>button { flex-shrink:0; } -.fd-kpis-five .fd-kpi>p, -.fd-kpis-five .fd-kpi-parts { margin-top:auto; min-height:24px; } -.fd-kpis-five .fd-kpi>p { align-items:center; } -.fd-kpis-five .fd-kpi-parts { gap:5px; } -.fd-kpis-five .fd-kpi-parts button { +.fd-kpis>.fd-kpi { display:flex; flex-direction:column; } +.fd-kpis .fd-kpi>button { flex-shrink:0; } +.fd-kpis .fd-kpi>p, +.fd-kpis .fd-kpi-parts { margin-top:auto; min-height:24px; } +.fd-kpis .fd-kpi>p { align-items:center; } +.fd-kpis .fd-kpi-parts { gap:5px; } +.fd-kpis .fd-kpi-parts button { height:24px; min-width:0; padding:3px 7px; line-height:18px; white-space:nowrap; } -.fd-kpis-five .fd-kpi-parts b { line-height:18px; } -.fd-kpis-five .fd-kpi-parts>span { line-height:24px; font-size:12px; } +.fd-kpis .fd-kpi-parts b { line-height:18px; } +.fd-kpis .fd-kpi-parts>span { line-height:24px; font-size:12px; } @media(max-width:1100px) { - .fd-kpis-five { grid-template-columns:repeat(2,minmax(0,1fr)); } - .fd-kpis-five>.fd-kpi:last-child { grid-column:1/-1; } + .fd-kpis { grid-template-columns:repeat(2,minmax(0,1fr)); } +} + +/* Keep the report-source breakdown and comparison readable at both card widths. */ +.fd-kpis>.fd-kpi { height:148px; } +.fd-kpis .fd-report-parts { margin-top:auto; } +.fd-kpis .fd-reported-kpi>p { min-height:16px; margin-top:6px; } +@media(max-width:767px) { + .fd-kpis>.fd-kpi { height:142px; } + .fd-kpis .fd-report-parts { gap:3px; } + .fd-kpis .fd-report-parts button { padding:3px 4px; } } diff --git a/src/server/routes/asset-faults/dashboard.test.ts b/src/server/routes/asset-faults/dashboard.test.ts index f859404..8d236eb 100644 --- a/src/server/routes/asset-faults/dashboard.test.ts +++ b/src/server/routes/asset-faults/dashboard.test.ts @@ -40,3 +40,97 @@ test('dashboard and report endpoints enforce authentication, permissions, dates const trend=await(await app.request('/f/trend?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(trend.totals.reported,0); const weekly=await(await app.request('/f/weekly-report?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(weekly.totals.reported,0); }); + +test('focus contains only L1 unclosed faults across all dates and keeps totals and full drill consistent', () => { + const focusRows = [ + fault('waiting', { reported_at: '2026-09-01 08:00:00' }), + fault('processing', { handling_status: '处理中', deadline_at: '2026-10-12 15:00:00' }), + fault('paused', { handling_status: '挂起', deadline_at: '2026-10-01 08:00:00' }), + fault('review', { handling_status: '审核中' }), + fault('unknown', { handling_status: '其他状态' }), + fault('sixth'), + fault('seventh'), + fault('closed', { handling_status: '已归档' }), + fault('l2-overdue', { fault_level: 2, deadline_at: '2026-10-01 08:00:00' }), + fault('l3', { fault_level: 3 }), + fault('l4', { fault_level: 4 }), + fault('unmarked', { fault_level: null }), + ]; + const dashboard = faultDashboard(focusRows, q, updatedAt); + assert.deepEqual(dashboard.focusTotals, { total: 7, overdue: 1 }); + assert.equal(dashboard.focus.length, 5); + assert.equal(dashboard.focus[0].id, 'processing'); + assert.ok(dashboard.focus.every(row => row.level === 'L1-特急' && row.status !== '已归档')); + const allFocus = dashboardAnalysis(focusRows, { ...q, scope: 'focus' }, updatedAt).rows; + assert.equal(allFocus.length, dashboard.focusTotals.total); + assert.ok(allFocus.some(row => row.id === 'waiting')); + assert.ok(allFocus.some(row => row.id === 'paused')); + assert.ok(allFocus.some(row => row.id === 'review')); + assert.ok(allFocus.every(row => !('vehicleKey' in row) && !('managerId' in row))); + assert.deepEqual(dashboardAnalysis(focusRows, { ...q, scope: 'focus', id: 'closed' }, updatedAt).rows, []); + const filtered = faultDashboard(focusRows, { ...q, level: 'L2-紧急' }, updatedAt); + assert.deepEqual(filtered.focus, []); + assert.deepEqual(filtered.focusTotals, { total: 0, overdue: 0 }); + assert.deepEqual(dashboardAnalysis(focusRows, { ...q, scope: 'focus', level: 'L2-紧急' }, updatedAt).rows, []); +}); +test('focus dashboard and full drill enforce permissions and business filters before selecting L1 faults', async () => { + const scopedRows = [fault('allowed'), fault('outside', { business_dept: '部门B' }), fault('closed', { handling_status: '已归档' }), fault('lower', { fault_level: 2 })]; + const user: AuthUser = { userId: 'test', loginName: 'test', userName: '测试', roles: [], permissionLevel: 'department', depName: '部门A', depCode: 'a' }; + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', user); await next(); }); + app.route('/f', createFaultRouter(async () => ({ rows: scopedRows, updatedAt }))); + const report = await (await app.request('/f/dashboard?start=2026-10-12&end=2026-10-13')).json(); + assert.deepEqual(report.focus.map((row: { id: string }) => row.id), ['allowed']); + assert.deepEqual(report.focusTotals, { total: 1, overdue: 0 }); + const response = await app.request('/f/dashboard-analysis?start=2026-10-12&end=2026-10-13&scope=focus'); + assert.equal(response.status, 200); + const detail = await response.json(); + assert.deepEqual(detail.rows.map((row: { id: string }) => row.id), ['allowed']); + const empty = await (await app.request('/f/dashboard-analysis?scope=focus&department=不存在')).json(); + assert.deepEqual(empty.rows, []); +}); + +test('report source follows explicit OneOS report method before legacy task linkage', () => { + const cases: [Record, 'operations' | 'customer'][] = [ + [{ report_method: '运维上报', source_work_order_id: '9007199254740993' }, 'operations'], + [{ report_method: '客户上报', source_work_order_id: null }, 'customer'], + [{ report_method: ' 客户上报 ', source_work_order_id: '123' }, 'customer'], + [{ report_method: null, source_work_order_id: '9007199254740993' }, 'customer'], + [{ report_method: '', source_work_order_id: null }, 'operations'], + [{ report_method: null, source_work_order_id: null, fault_source: 6 }, 'operations'], + ]; + for (const [raw, expected] of cases) assert.equal(fault('source', raw).reportMethod, expected); +}); +test('reported source counts reconcile with cohort, filtered drills, permissions and export labels', async () => { + const sourceRows = [ + fault('ops-linked', { report_method: '运维上报', source_work_order_id: '123' }), + fault('ai-explicit', { report_method: '客户上报', source_work_order_id: null }), + fault('ai-legacy', { report_method: null, source_work_order_id: '9007199254740993', handling_status: '已归档' }), + fault('ops-legacy', { report_method: null, source_work_order_id: null }), + fault('previous-ai', { report_method: '客户上报', reported_at: '2026-10-10 10:00:00' }), + fault('hidden-ai', { report_method: '客户上报', business_dept: '部门B' }), + ]; + const user: AuthUser = { userId: 'test', loginName: 'test', userName: '测试', roles: [], permissionLevel: 'department', depName: '部门A', depCode: 'a' }; + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', user); await next(); }); + app.route('/f', createFaultRouter(async () => ({ rows: sourceRows, updatedAt }))); + const query = new URLSearchParams(q); + const report = await (await app.request(`/f/dashboard?${query}`)).json(); + assert.equal(report.totals.reported, 4); + assert.equal(report.totals.operationsReported, 2); + assert.equal(report.totals.customerReported, 2); + assert.equal(report.totals.operationsReported + report.totals.customerReported, report.totals.reported); + assert.equal(report.options.reportMethod.find((o: { value: string }) => o.value === 'customer').label, 'AI提报'); + assert.equal(report.options.reportMethod.find((o: { value: string }) => o.value === 'operations').label, '运维提报'); + for (const [method, expected] of [['operations', ['ops-linked', 'ops-legacy']], ['customer', ['ai-explicit', 'ai-legacy']]] as const) { + const response = await app.request(`/f/dashboard-analysis?${query}&scope=cohort&reportMethod=${method}`); + assert.equal(response.status, 200); + const detail = await response.json(); + assert.deepEqual(detail.rows.map((row: { id: string }) => row.id).sort(), [...expected].sort()); + const filtered = await (await app.request(`/f/dashboard?${query}&reportMethod=${method}`)).json(); + assert.equal(filtered.totals.reported, expected.length); + assert.equal(filtered.totals.operationsReported + filtered.totals.customerReported, filtered.totals.reported); + } + const empty = await (await app.request(`/f/dashboard?${query}®ion=不存在`)).json(); + assert.equal(empty.totals.operationsReported, 0); assert.equal(empty.totals.customerReported, 0); +}); diff --git a/src/server/routes/asset-faults/dashboard.ts b/src/server/routes/asset-faults/dashboard.ts index 3e817b5..1dfa7b7 100644 --- a/src/server/routes/asset-faults/dashboard.ts +++ b/src/server/routes/asset-faults/dashboard.ts @@ -1,4 +1,4 @@ -import { FAULT_DASHBOARD_FILTERS, type FaultDashboardDimension, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults.js'; +import { FAULT_REPORT_METHODS, FAULT_DASHBOARD_FILTERS, type FaultDashboardDimension, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults.js'; import { faultAnalysis, faultOverview, isOverdueFault, type ScopedFault } from './model.js'; import { parseFaultTrendQuery, selectFaultCohort } from './trend.js'; @@ -7,9 +7,11 @@ export function dashboardValues(row: ScopedFault, key: FaultDashboardDimension): if (key === 'level') return [row.level || '未标注']; if (key === 'category') return row.categories?.length ? row.categories : ['未标注']; if (key === 'inventory') return [row.inventory]; + if (key === 'reportMethod') return [row.reportMethod || 'operations']; return [row.assetCell[key]]; } function label(row: ScopedFault, key: FaultDashboardDimension, value: string) { + if (key === 'reportMethod') return FAULT_REPORT_METHODS[value as keyof typeof FAULT_REPORT_METHODS] || value; return key === 'customer' ? row.customerLabel : key === 'handler' ? row.handlers.find(h => h.value === value)?.label || value : value; } export function filterDashboardRows(rows: ScopedFault[], params: Record) { @@ -18,15 +20,19 @@ export function filterDashboardRows(rows: ScopedFault[], params: Record) { const query = parseFaultTrendQuery({ start: params.start, end: params.end }); const scope = params.scope || 'cohort'; - if (!['cohort', 'open', 'waiting', 'processing', 'overdue', 'ledger', 'reported', 'closed'].includes(scope)) throw new Error('无效统计范围'); + if (!['focus', 'cohort', 'open', 'waiting', 'processing', 'overdue', 'ledger', 'reported', 'closed'].includes(scope)) throw new Error('无效统计范围'); return { ...query, scope: scope as FaultDashboardScope }; } +function selectFocusFaults(rows: ScopedFault[]) { + return rows.filter(row => row.level === 'L1-特急' && row.status !== '已归档'); +} export function dashboardAnalysis(rows: ScopedFault[], params: Record, updatedAt: string) { const query = parseDashboardQuery(params); let selected = filterDashboardRows(rows, params); if (params.id) selected = selected.filter(row => row.id === params.id); if (query.scope === 'cohort') selected = selectFaultCohort(selected, query); - return faultAnalysis(selected, query.scope === 'cohort' ? 'ledger' : query.scope, updatedAt); + if (query.scope === 'focus') selected = selectFocusFaults(selected); + return faultAnalysis(selected, query.scope === 'cohort' || query.scope === 'focus' ? 'ledger' : query.scope, updatedAt); } export function faultDashboard(visible: ScopedFault[], params: Record, updatedAt: string): FaultDashboardResponse { const query = parseDashboardQuery(params); @@ -56,9 +62,11 @@ export function faultDashboard(visible: ScopedFault[], params: Record r.reportedAt && r.reportedAt.slice(0,10) >= previousStart && r.reportedAt.slice(0,10) < query.start).length; - const focus = faultAnalysis([...open].sort((a,b)=> Number(isOverdueFault(b,updatedAt))-Number(isOverdueFault(a,updatedAt)) || (b.reportedAt||'').localeCompare(a.reportedAt||'')).slice(0,5), 'ledger', updatedAt).rows; - return { start:query.start,end:query.end,updatedAt,options,distributions,matrix,focus, - totals: { reported:cohort.length,vehicles:vehicles(cohort),customers:new Set(cohort.filter(r=>!['unlinked','inventory-unlinked'].includes(r.customer)).map(r=>r.customer)).size, + const focusRows = selectFocusFaults(all); + const focusTotals = { total: focusRows.length, overdue: focusRows.filter(row => isOverdueFault(row, updatedAt)).length }; + const focus = faultAnalysis([...focusRows].sort((a,b)=> Number(isOverdueFault(b,updatedAt))-Number(isOverdueFault(a,updatedAt)) || (b.reportedAt||'').localeCompare(a.reportedAt||'')).slice(0,5), 'ledger', updatedAt).rows; + return { start:query.start,end:query.end,updatedAt,options,distributions,matrix,focus,focusTotals, + totals: { reported:cohort.length,operationsReported:cohort.filter(r=>r.reportMethod!=='customer').length,customerReported:cohort.filter(r=>r.reportMethod==='customer').length,vehicles:vehicles(cohort),customers:new Set(cohort.filter(r=>!['unlinked','inventory-unlinked'].includes(r.customer)).map(r=>r.customer)).size, previous,change:previous ? (cohort.length-previous)/previous*100 : null,open:overview.open,waiting:overview.waiting,processing:overview.processing,overdue:overview.overdue, longestOverdueDays:Math.max(0,...overdue.map(r=>Math.ceil((Date.parse(updatedAt)-Date.parse(`${r.deadlineAt!.replace(' ','T')}+08:00`))/86400000))), todayReported:overview.reported,todayClosed:overview.closed,ledger:all.length }, diff --git a/src/server/routes/asset-faults/model.ts b/src/server/routes/asset-faults/model.ts index b9bc15a..01a561c 100644 --- a/src/server/routes/asset-faults/model.ts +++ b/src/server/routes/asset-faults/model.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse } from '../../../shared/assets/faults.js'; +import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse, type FaultReportMethod } from '../../../shared/assets/faults.js'; import type { AuthUser } from '../../auth/types.js'; import { filterByPermission, maskCustomerName } from '../../auth/permissions.js'; import type { AssetAnalysisCell } from '../../../shared/assets/analytics.js'; @@ -28,6 +28,13 @@ function array(value: unknown, preserveIds = false): unknown[] { } const text = (value: unknown) => String(value ?? '').trim(); const positiveId = (value: unknown) => /^[1-9]\d*$/.test(text(value)); +// Match OneOS reportMethod: explicit source wins, task linkage is a legacy fallback. +export function faultReportMethod(row: RawFault): FaultReportMethod { + const method = text(row.report_method); + if (method === '运维上报') return 'operations'; + if (method === '客户上报') return 'customer'; + return positiveId(row.source_work_order_id) ? 'customer' : 'operations'; +} export function faultHandlers(row: RawFault, directory: Map): FaultOption[] { const candidates = [ [array(row.work_ids, true), array(row.work_names)], @@ -81,7 +88,7 @@ export function mapFault(row: RawFault, directory: Map): ScopedF vehicleKey: text(row.matched_vehicle_id) || (text(row.plate_number) ? `plate:${text(row.plate_number)}` : ''), level: text(row.level_label) || ({ '1': 'L1-特急', '2': 'L2-紧急', '3': 'L3-一般', '4': 'L4-提示' } as Record)[text(row.fault_level)] || '未标注', categories: [...new Set(text(row.fault_categories).split(/[,,、;;]/).map(v => v.trim()).filter(Boolean))], - progress: text(row.solve_process), + progress: text(row.solve_process), reportMethod: faultReportMethod(row), id: text(row.id), code: text(row.fault_code), plate: text(row.plate_number) || '未关联车辆', description: text(row.fault_desc), reportedAt: text(row.reported_at) || null, archivedAt: text(row.archived_at) || null, deadlineAt: text(row.deadline_at) || null, receivedAt: text(row.received_at) || null, city, status: normalizeFaultStatus(row.handling_status), assetCell, diff --git a/src/server/routes/asset-faults/repository.ts b/src/server/routes/asset-faults/repository.ts index 15477f1..e1dc143 100644 --- a/src/server/routes/asset-faults/repository.ts +++ b/src/server/routes/asset-faults/repository.ts @@ -4,7 +4,8 @@ import { mapFault, type RawFault } from './model.js'; // OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause. export const FAULT_SQL = `SELECT - f.fault_level, f.fault_categories, f.solve_process, + f.fault_level, f.fault_categories, f.solve_process, f.report_method, + CAST(f.source_work_order_id AS CHAR) source_work_order_id, CAST(f.id AS CHAR) id, f.fault_code, f.plate_number, f.fault_desc, f.handling_status, DATE_FORMAT(f.fault_report_time, '%Y-%m-%d %H:%i:%s') reported_at, DATE_FORMAT(f.archived_at, '%Y-%m-%d %H:%i:%s') archived_at, diff --git a/src/shared/assets/faults.ts b/src/shared/assets/faults.ts index 1b56219..0f34d6c 100644 --- a/src/shared/assets/faults.ts +++ b/src/shared/assets/faults.ts @@ -8,13 +8,15 @@ export type FaultDimension = keyof typeof FAULT_DIMENSIONS; export type FaultMetric = 'reported' | 'closed' | 'open'; export interface FaultOption { value: string; label: string } export interface FaultCounts { reported: number; closed: number; open: number } +export const FAULT_REPORT_METHODS = { operations: '运维提报', customer: 'AI提报' } as const; +export type FaultReportMethod = keyof typeof FAULT_REPORT_METHODS; export interface FaultRecord { id: string; code: string; plate: string; description: string; reportedAt: string | null; archivedAt: string | null; deadlineAt: string | null; receivedAt: string | null; city: string; status: string; inventory: string; customer: string; customerLabel: string; department: string; handlers: FaultOption[]; - level?: string; categories?: string[]; progress?: string; + level?: string; categories?: string[]; progress?: string; reportMethod?: FaultReportMethod; } export interface FaultQuery { start: string; end: string; groupBy: FaultDimension; @@ -65,17 +67,18 @@ export interface FaultWeeklyResponse { days: (Record & { date: string })[]; } -export const FAULT_DASHBOARD_FILTERS = { region: '区域', customer: '客户', model: '品牌车型', city: '城市', department: '业务部门', handler: '运维处置人', level: '故障程度', category: '故障部位', inventory: '库存状态' } as const; +export const FAULT_DASHBOARD_FILTERS = { region: '区域', customer: '客户', model: '品牌车型', city: '城市', department: '业务部门', handler: '运维处置人', level: '故障程度', category: '故障部位', inventory: '库存状态', reportMethod: '提报来源' } as const; export type FaultDashboardDimension = keyof typeof FAULT_DASHBOARD_FILTERS; export type FaultDashboardFilters = Partial>; -export type FaultDashboardScope = 'cohort' | 'open' | 'waiting' | 'processing' | 'overdue' | 'ledger' | 'reported' | 'closed'; +export type FaultDashboardScope = 'focus' | 'cohort' | 'open' | 'waiting' | 'processing' | 'overdue' | 'ledger' | 'reported' | 'closed'; export interface FaultDistribution { value: string; label: string; reported: number; closed: number; unclosed: number; vehicles: number; open: number } export interface FaultDashboardResponse { start: string; end: string; updatedAt: string; - totals: { reported: number; vehicles: number; customers: number; previous: number; change: number | null; open: number; waiting: number; processing: number; overdue: number; longestOverdueDays: number; todayReported: number; todayClosed: number; ledger: number }; + totals: { reported: number; operationsReported: number; customerReported: number; vehicles: number; customers: number; previous: number; change: number | null; open: number; waiting: number; processing: number; overdue: number; longestOverdueDays: number; todayReported: number; todayClosed: number; ledger: number }; options: Record; distributions: Record<'region' | 'level' | 'customer' | 'model', FaultDistribution[]>; matrix: { models: FaultOption[]; categories: FaultOption[]; cells: { model: string; category: string; count: number }[]; totals: Record }; focus: FaultAnalysisRecord[]; + focusTotals: { total: number; overdue: number }; quality: { missingLevel: number; missingCategory: number; missingVehicle: number; missingDeadline: number; missingReport: number }; }