diff --git a/skills/skill_staging/scenes/fault-details-report/scene.json b/skills/skill_staging/scenes/fault-details-report/scene.json index aa12443..871e4a1 100644 --- a/skills/skill_staging/scenes/fault-details-report/scene.json +++ b/skills/skill_staging/scenes/fault-details-report/scene.json @@ -3,11 +3,11 @@ "name": "故障明细", "category": "report", "entry": "index.html", - "summary": "查询故障明细行并生成包含明细与汇总分区的结构化报表产物。", + "summary": "查询故障明细行,生成包含明细与汇总分区的结构化报表产物,并记录导出与报表历史结果。", "inputs": ["period"], "outputs": ["report-artifact"], "dependencies": ["browser", "report-history", "local-report-service"], - "actions": ["query", "collect-report", "build-summary-section"], + "actions": ["query", "collect-report", "build-summary-section", "attempt-export", "record-report-log"], "tags": ["fault", "report", "xlsx", "details", "browser"], "skill": { "package": "fault-details-report", diff --git a/skills/skill_staging/skills/fault-details-report/SKILL.md b/skills/skill_staging/skills/fault-details-report/SKILL.md index 8602cfa..6a9291f 100644 --- a/skills/skill_staging/skills/fault-details-report/SKILL.md +++ b/skills/skill_staging/skills/fault-details-report/SKILL.md @@ -32,9 +32,12 @@ Do not use this skill for: 1. Read the selected start and end time from the page date-range control. 2. Collect raw fault-detail rows from the source page's repair-order query. 3. Normalize rows into the canonical detail-column order defined by the page export schema. -4. Derive the summary rows used by the secondary summary sheet. -5. Return the structured artifact before any prose summary. -6. If required columns are missing or summary derivation is incomplete, mark the result as partial. +4. Apply the original classification table and `qxxcjl`-based reason heuristics to fill `sxfl1`, `sxfl2`, `sxfl3`, `gzsb`, and `gzyy`. +5. Derive the summary rows used by the secondary summary sheet. +6. Attempt localhost export through `faultDetailsExportXLSXS`. +7. Attempt report-history write through `setReportLog` when export returns a path. +8. Return the structured artifact before any prose summary. +9. If required columns are missing or summary derivation is incomplete, mark the result as partial or error according to the artifact contract. ## Runtime Contract @@ -57,7 +60,11 @@ Do not use this skill for: { "type": "report-artifact", "report_name": "fault-details-report", - "period": "", + "period": "2026-03", + "selected_range": { + "start": "2026-03-08 16:00:00", + "end": "2026-03-09 16:00:00" + }, "columns": [ "qxdbh", "gssgs", @@ -114,8 +121,24 @@ Do not use this skill for: "rows": [] } ], - "status": "ok", - "partial_reasons": [] + "counts": { + "detail_rows": 0, + "summary_rows": 0 + }, + "status": "partial", + "partial_reasons": ["report_log_failed"], + "downstream": { + "export": { + "attempted": true, + "success": true, + "path": "http://localhost/export.xlsx" + }, + "report_log": { + "attempted": true, + "success": false, + "error": "500" + } + } } ``` diff --git a/skills/skill_staging/skills/fault-details-report/SKILL.toml b/skills/skill_staging/skills/fault-details-report/SKILL.toml index a64cc79..c759c3c 100644 --- a/skills/skill_staging/skills/fault-details-report/SKILL.toml +++ b/skills/skill_staging/skills/fault-details-report/SKILL.toml @@ -1,6 +1,6 @@ [skill] name = "fault-details-report" -description = "Use when the user wants to collect fault-detail rows and export a structured fault report artifact or spreadsheet." +description = "Use when the user wants to collect fault-detail rows from the business page, derive the summary sheet, attempt localhost export, and record report history in one structured report artifact workflow." version = "0.1.0" author = "sgclaw" tags = ["fault", "report", "xlsx", "details", "browser"] @@ -11,6 +11,6 @@ prompts = [ [[tools]] name = "collect_fault_details" -description = "Collect raw fault-detail rows and prepare the detail-plus-summary report artifact shell from the source business page." +description = "Collect fault-detail rows from the source business page, normalize canonical detail fields, derive summary rows, attempt localhost export/report-log, and return one structured report artifact." kind = "browser_script" command = "scripts/collect_fault_details.js" diff --git a/skills/skill_staging/skills/fault-details-report/references/collection-flow.md b/skills/skill_staging/skills/fault-details-report/references/collection-flow.md index 32c0c59..e4d03ee 100644 --- a/skills/skill_staging/skills/fault-details-report/references/collection-flow.md +++ b/skills/skill_staging/skills/fault-details-report/references/collection-flow.md @@ -14,13 +14,17 @@ ## First-pass Collection Steps 1. Open or attach to the source page. -2. Read the selected start and end datetime. +2. Read the selected start and end datetime from the page controls; keep this as the source-of-truth collection range. 3. Ensure the D4 business page session is available. 4. Trigger the repair-order query used by the page logic. 5. Collect raw fault-detail rows. -6. Normalize rows using the canonical column order declared in `excleIni[0].cols`. -7. Derive the summary-sheet rows from grouped detail rows. -8. Return a structured artifact before any export-file or prose step. +6. Filter out rows with `statusName == "回退营销"`. +7. Normalize rows using the canonical column order declared in `excleIni[0].cols`. +8. Apply the original classification table and `qxxcjl` heuristics to derive `sxfl1`, `sxfl2`, `sxfl3`, `gzsb`, and `gzyy`. +9. Derive the summary-sheet rows from grouped detail rows. +10. Attempt localhost export through `faultDetailsExportXLSXS`. +11. If export returns a path, attempt report-history write through `setReportLog`. +12. Return one structured artifact describing collection plus downstream outcomes. ## Dependencies diff --git a/skills/skill_staging/skills/fault-details-report/references/data-quality.md b/skills/skill_staging/skills/fault-details-report/references/data-quality.md index 1d65b60..d381868 100644 --- a/skills/skill_staging/skills/fault-details-report/references/data-quality.md +++ b/skills/skill_staging/skills/fault-details-report/references/data-quality.md @@ -71,6 +71,25 @@ These may be blank in some rows but should still be preserved when present: - `gzyy` - `bz` +## Classification And Reason Rules + +The staged skill must port the original package classification table in full. That table maps `sjflMc` + `ejflMc` combinations into: + +- `sxfl1` +- `sxfl2` +- `sxfl3` +- `gzsb` +- `dwcFl` + +It also uses `qxxcjl` text heuristics to derive `gzyy`, including direct customer-cause overrides such as: + +- `客户原因导致表计烧损` +- `客户原因导致表前线断线` +- `客户原因导致表前开关跳闸` +- `客户表后线烧损` + +When no direct override matches, reason extraction should fall back to the original `故障现象是...` / `经查...` text slicing behavior. + ## Summary-Derivation Expectations The first-pass package should also be able to derive summary rows keyed around: @@ -85,11 +104,24 @@ The first-pass package should also be able to derive summary rows keyed around: - `sbdSbCount` - `gyGzCount` - `dyGzCount` +- `tqdzCount` +- `tqbxCount` +- `dyxlCount` +- `bqxCount` +- `jllCount` +- `bhxCount` +- `qftdCount` -## Partial Rules +`allCount` follows the original package rule: -- If detail rows are collected but one or more required detail columns cannot be mapped, set `status` to `partial`. +- `allCount = wxCount + khcCount + gyGzCount + dyGzCount + sbdSbCount` + +## Partial And Error Rules + +- If detail rows are collected but some rows fail normalization while the canonical detail table remains usable, set `status` to `partial` and record the degraded stage. +- If required fields are missing so broadly that canonical detail rows cannot be produced at all, set `status` to `error`. - If the detail table is available but the summary sheet cannot be derived completely, set `status` to `partial`. +- If summary derivation cannot start because normalized rows are structurally unusable, set `status` to `error`. - If export generation or report-log writing fails after collection succeeds, keep the artifact and record the downstream failure in `partial_reasons`. - Do not silently drop required columns. diff --git a/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js b/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js index 4f5ffb9..4af6fbc 100644 --- a/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js +++ b/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js @@ -1,75 +1,791 @@ +const REPORT_NAME = 'fault-details-report'; +const REPORT_LABEL = '故障明细'; +const GSSGS = '甘肃省电力公司'; + const DETAIL_COLUMNS = [ - "qxdbh", - "gssgs", - "sgs", - "gddw", - "gds", - "slsj", - "yjflMc", - "ejflMc", - "sjflMc", - "gzms", - "yhbh", - "yhmc", - "lxr", - "gzdd", - "lxdh", - "bxsj", - "gdsj", - "clzt", - "qxxcjl", - "bdz", - "line", - "pb", - "sxfl1", - "sxfl2", - "sxfl3", - "gzsb", - "gzyy", - "bz" + 'qxdbh', + 'gssgs', + 'sgs', + 'gddw', + 'gds', + 'slsj', + 'yjflMc', + 'ejflMc', + 'sjflMc', + 'gzms', + 'yhbh', + 'yhmc', + 'lxr', + 'gzdd', + 'lxdh', + 'bxsj', + 'gdsj', + 'clzt', + 'qxxcjl', + 'bdz', + 'line', + 'pb', + 'sxfl1', + 'sxfl2', + 'sxfl3', + 'gzsb', + 'gzyy', + 'bz' ]; const SUMMARY_COLUMNS = [ - "index", - "gsName", - "fwDept", - "className", - "allCount", - "wxCount", - "khcCount", - "sbdSbCount", - "gyGzCount", - "dyGzCount", - "tqdzCount", - "tqbxCount", - "dyxlCount", - "bqxCount", - "jllCount", - "bhxCount", - "qftdCount" + 'index', + 'gsName', + 'fwDept', + 'className', + 'allCount', + 'wxCount', + 'khcCount', + 'sbdSbCount', + 'gyGzCount', + 'dyGzCount', + 'tqdzCount', + 'tqbxCount', + 'dyxlCount', + 'bqxCount', + 'jllCount', + 'bhxCount', + 'qftdCount' ]; -function collectFaultDetails(input = {}) { - return { - type: "report-artifact", - report_name: "fault-details-report", - period: input.period || "", - columns: DETAIL_COLUMNS, - rows: [], +const CLASSIFICATION_RULES = [ + { + name: '欠费停电', + reList: ['欠费', '余额不足'], + type: '用户侧', + dwcFl: '' + }, + { + name: '客户内部故障', + reList: ['客户内部故障'], + type: '用户侧', + dwcFl: '' + }, + { + name: '表后线', + reList: ['表后线'], + type: '用户侧', + dwcFl: '' + }, + { + name: '输变电设备', + reList: ['输变电设备'], + type: '电网侧', + dwcFl: '输变电设备' + }, + { + name: '高压故障', + reList: [ + '电杆(塔)', + '杆(塔)基础', + '连接线-架空线路', + '"连接线-高压计量设备"', + '柱上隔离开关-架空线路', + '横担-架空线路', + '绝缘子-架空线路', + '导线-架空线路', + '跌落式熔断器', + '柱上断路器', + '二次及自动化装置', + '柱上负荷开关', + '金具-架空线路', + '高压电容器', + '避雷装置-架空线路', + '避雷装置-电缆线路', + '避雷装置-配电站房设备', + '接地装置-架空线路', + '接地装置-电缆线路', + '接地装置-配电站房设备', + '电缆本体-电缆线路', + '电缆终端头-电缆线路', + '电缆中间接头-电缆线路', + '油浸式变压器', + '干式变压器', + '隔离开关-配电站房设备', + '基础-配电站房设备', + '端子排-配电站房设备', + '电流互感器-配电站房设备', + '电流互感器-高压计量设备', + '电压互感器', + '穿墙套管', + '母排-配电站房设备', + '熔断器-配电站房设备', + '断路器-配电站房设备', + '负荷开关', + '计量表计-高压计量设备', + '接线端子盒', + '计量柜' + ], + type: '电网侧', + dwcFl: '高压故障' + }, + { + name: '台区刀闸', + reList: ['柱上隔离开关-低压架空线路', '电缆终端头', '隔离开关-低压设备'], + type: '电网侧', + dwcFl: '低压故障' + }, + { + name: '台区保险', + reList: ['绝缘子-低压架空线路', '熔断器-低压设备', '断路器-低压设备', '漏电保护器'], + type: '电网侧', + dwcFl: '低压故障' + }, + { + name: '低压线路', + reList: [ + '连接线-低压架空线路', + '导线-低压架空线路', + '横担-低压架空线路', + '金具-低压架空线路', + '避雷装置-低压架空线路', + '避雷装置-低压电缆线路路', + '避雷装置-低压设备', + '接地装置-低压架空线路', + '接地装置-低压设备', + '电缆本体-低压电缆线路路', + '电缆终端头-低压电缆线路路', + '电缆中间接头-低压电缆线路路', + '基础-低压架空线路', + '基础-低压设备', + '端子排-低压设备', + '电流互感器-低压设备', + '母排-低压设备', + '接户线', + '电杆', + '连接装置', + '电容器', + '交流接触器', + '低压母线槽', + '频率异常', + '谐波异常', + '电压异常' + ], + type: '电网侧', + dwcFl: '低压故障' + }, + { + name: '表前线', + reList: ['连接线-低压计量设备', '接线端子', '主干线', '表前线'], + type: '电网侧', + dwcFl: '低压故障' + }, + { + name: '计量类', + reList: ['电流互感器-低压计量设备', '计量表计-低压计量设备', '计量箱(柜)', '表前开关(熔丝)'], + type: '电网侧', + dwcFl: '低压故障' + } +]; + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim() !== ''; +} + +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function pickFirstNonEmpty(...values) { + for (const value of values) { + if (isNonEmptyString(value)) { + return value.trim(); + } + } + return ''; +} + +function extractFaultReason(qxxcjl) { + const text = normalizeText(qxxcjl); + if (!text) { + return ''; + } + + const directReasons = [ + ['客户原因导致表计烧损', '表计烧损'], + ['客户原因导致表前线断线', '表前线断线'], + ['客户原因导致表前开关跳闸', '表前开关跳闸'], + ['客户表后线烧损', '表后线烧损'], + ['表后线烧损', '表后线烧损'] + ]; + for (const [needle, reason] of directReasons) { + if (text.includes(needle)) { + return reason; + } + } + + const markers = [ + ['故障现象是', [',', '。', ';', ';']], + ['经查', ['。', ',', ';', ';']] + ]; + for (const [marker, stops] of markers) { + const index = text.indexOf(marker); + if (index === -1) { + continue; + } + let content = text.slice(index + marker.length).trim(); + content = content.replace(/^[::,,\s]+/, ''); + let end = content.length; + for (const stop of stops) { + const stopIndex = content.indexOf(stop); + if (stopIndex !== -1 && stopIndex < end) { + end = stopIndex; + } + } + const reason = content.slice(0, end).trim(); + if (reason) { + return reason; + } + } + + return ''; +} + +function matchClassification(raw) { + const sjflMc = normalizeText(raw.sjflMc); + const ejflMc = normalizeText(raw.ejflMc); + const qxxcjl = normalizeText(raw.qxxcjl); + + let result = { + sxfl1: '无效', + sxfl2: '', + sxfl3: '', + gzsb: '', + gzyy: '', + fl4: '', + dwcFl: '' + }; + + if (!sjflMc || sjflMc === '无故障') { + return result; + } + + for (const rule of CLASSIFICATION_RULES) { + for (const matcher of rule.reList) { + if (matcher.includes('-')) { + const [threeName, twoName] = matcher.split('-', 2); + if (!sjflMc.includes(threeName) || ejflMc !== twoName) { + continue; + } + } else if (!sjflMc.includes(matcher)) { + continue; + } + + result = { + sxfl1: '有效', + sxfl2: rule.type, + sxfl3: rule.dwcFl, + gzsb: rule.name, + gzyy: extractFaultReason(qxxcjl), + fl4: rule.name, + dwcFl: rule.dwcFl + }; + + if (sjflMc.includes('计量表计') && ejflMc === '低压计量设备' && qxxcjl.includes('客户原因导致表计烧损')) { + result.sxfl2 = '用户侧'; + result.fl4 = '客户内部故障'; + } + if (sjflMc.includes('表前线') && ejflMc === '低压计量设备' && qxxcjl.includes('客户原因导致表前线断线')) { + result.sxfl2 = '用户侧'; + result.fl4 = '客户内部故障'; + } + if (matcher === '表前开关(熔丝)' && qxxcjl.includes('客户原因导致表前开关跳闸')) { + result.sxfl2 = '用户侧'; + result.fl4 = '客户内部故障'; + } + + return result; + } + } + + return result; +} + +function normalizeDetailRow(raw, context = {}) { + const qxdbh = pickFirstNonEmpty(raw.qxdbh, raw.id); + const bxsj = pickFirstNonEmpty(raw.bxsj); + if (!qxdbh || !bxsj) { + return null; + } + + const companyName = pickFirstNonEmpty(context.companyName, raw.cityName); + const sgs = companyName.includes('嘉峪关') + ? companyName + : pickFirstNonEmpty(raw.cityName, companyName); + const classification = matchClassification(raw); + + const row = { + qxdbh, + gssgs: GSSGS, + sgs, + gddw: pickFirstNonEmpty(raw.maintOrgName), + gds: pickFirstNonEmpty(raw.maintGroupName), + slsj: bxsj, + yjflMc: pickFirstNonEmpty(raw.yjflMc), + ejflMc: pickFirstNonEmpty(raw.ejflMc), + sjflMc: pickFirstNonEmpty(raw.sjflMc), + gzms: pickFirstNonEmpty(raw.gzms), + yhbh: pickFirstNonEmpty(raw.yhbh, raw.consNo), + yhmc: pickFirstNonEmpty(raw.yhmc, raw.consName), + lxr: pickFirstNonEmpty(raw.lxr, raw.linkMan), + gzdd: pickFirstNonEmpty(raw.gzdd, raw.gzAddress), + lxdh: pickFirstNonEmpty(raw.lxdh, raw.linkPhone), + bxsj, + gdsj: pickFirstNonEmpty(raw.gdsj), + clzt: '处理完成', + qxxcjl: pickFirstNonEmpty(raw.qxxcjl), + bdz: pickFirstNonEmpty(raw.bdzMc), + line: pickFirstNonEmpty(raw.xlmc10), + pb: pickFirstNonEmpty(raw.byqmc), + sxfl1: classification.sxfl1, + sxfl2: classification.sxfl2, + sxfl3: classification.sxfl3, + gzsb: classification.gzsb, + gzyy: classification.gzyy, + bz: pickFirstNonEmpty(raw.bz), + fl4: classification.fl4, + dwcFl: classification.dwcFl, + f14: classification.gzsb + }; + + return row; +} + +function deriveSummaryRows(detailRows, context = {}) { + const grouped = new Map(); + for (const row of detailRows) { + const key = pickFirstNonEmpty(row.gds, '未分组'); + if (!grouped.has(key)) { + grouped.set(key, []); + } + grouped.get(key).push(row); + } + + const companyName = pickFirstNonEmpty(context.companyName); + return Array.from(grouped.entries()).map(([groupName, rows], index) => { + const summaryRow = { + index: index + 1, + gsName: companyName || pickFirstNonEmpty(rows[0]?.sgs), + fwDept: companyName === '国网嘉峪关供电公司' ? '供电服务部' : pickFirstNonEmpty(rows[0]?.gddw), + className: companyName === '国网嘉峪关供电公司' ? '急修班' : groupName, + wxCount: rows.filter((item) => item.sxfl1 === '无效').length, + khcCount: rows.filter((item) => item.sxfl2 === '用户侧' && pickFirstNonEmpty(item.f14, item.gzsb) !== '表后线' && pickFirstNonEmpty(item.fl4, item.gzsb) !== '欠费停电').length, + sbdSbCount: rows.filter((item) => item.sxfl2 === '电网侧' && item.dwcFl === '输变电设备').length, + gyGzCount: rows.filter((item) => item.sxfl2 === '电网侧' && item.dwcFl === '高压故障').length, + dyGzCount: rows.filter((item) => item.sxfl2 === '电网侧' && item.dwcFl === '低压故障').length, + tqdzCount: rows.filter((item) => item.gzsb === '台区刀闸').length, + tqbxCount: rows.filter((item) => item.gzsb === '台区保险').length, + dyxlCount: rows.filter((item) => item.gzsb === '低压线路').length, + bqxCount: rows.filter((item) => item.gzsb === '表前线').length, + jllCount: rows.filter((item) => item.gzsb === '计量类').length, + bhxCount: rows.filter((item) => item.gzsb === '表后线').length, + qftdCount: rows.filter((item) => item.gzsb === '欠费停电').length + }; + summaryRow.allCount = summaryRow.wxCount + summaryRow.khcCount + summaryRow.sbdSbCount + summaryRow.gyGzCount + summaryRow.dyGzCount; + return summaryRow; + }); +} + +function determineArtifactStatus({ blockedReason, fatalError, partialReasons = [], detailRows = [] }) { + if (blockedReason) { + return 'blocked'; + } + if (fatalError) { + return 'error'; + } + if (partialReasons.length > 0) { + return 'partial'; + } + if (!detailRows.length) { + return 'empty'; + } + return 'ok'; +} + +function buildFaultDetailsArtifact({ + period = '', + selectedRange, + detailRows = [], + summaryRows = [], + partialReasons = [], + blockedReason = '', + fatalError = '', + downstream +}) { + const reasons = Array.from(new Set([ + ...partialReasons.filter(Boolean), + blockedReason || null, + fatalError || null + ].filter(Boolean))); + const status = determineArtifactStatus({ + blockedReason, + fatalError, + partialReasons: reasons, + detailRows + }); + + const artifact = { + type: 'report-artifact', + report_name: REPORT_NAME, + period, + columns: DETAIL_COLUMNS.slice(), + rows: detailRows, sections: [ { - name: "summary-sheet", - columns: SUMMARY_COLUMNS, - rows: [] + name: 'summary-sheet', + columns: SUMMARY_COLUMNS.slice(), + rows: summaryRows } ], - status: "ok", - partial_reasons: [] + counts: { + detail_rows: detailRows.length, + summary_rows: summaryRows.length + }, + status, + partial_reasons: reasons + }; + + if (selectedRange) { + artifact.selected_range = { + start: pickFirstNonEmpty(selectedRange.start), + end: pickFirstNonEmpty(selectedRange.end) + }; + } + if (downstream) { + artifact.downstream = downstream; + } + + return artifact; +} + +function buildExportTitles(monthLabel) { + const suffix = monthLabel ? `(${monthLabel})` : ''; + return [ + { + sheet: `${REPORT_LABEL}${suffix}`, + cols: DETAIL_COLUMNS.map((key) => [key, key]) + }, + { + sheet: `汇总${suffix}`, + cols: SUMMARY_COLUMNS.map((key) => [key, key]) + } + ]; +} + +function buildEmptyExportRow() { + return { qxdbh: '今日无数据' }; +} + +function buildExportPayload({ detailRows, summaryRows, context, selectedRange }) { + const end = pickFirstNonEmpty(selectedRange?.end); + const monthLabel = end && /^\d{4}-\d{2}/.test(end) ? end.slice(5, 7) : ''; + const reportDay = end ? end.slice(5, 10).replace('-', '月') + '日' : ''; + const companyName = pickFirstNonEmpty(context.companyName, '国网甘肃供电公司'); + return { + titles: buildExportTitles(monthLabel), + date: `${companyName}故障报修明细表(${reportDay})`, + datas: [detailRows.length > 0 ? detailRows : [buildEmptyExportRow()], summaryRows] }; } -module.exports = { - DETAIL_COLUMNS, - SUMMARY_COLUMNS, - collectFaultDetails -}; +async function postJson(url, payload) { + if (typeof fetch === 'function') { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error(`${url} responded with ${response.status}`); + } + return response.json(); + } + + const jq = globalThis.$; + if (jq && typeof jq.ajax === 'function') { + return new Promise((resolve, reject) => { + jq.ajax({ + url, + type: 'POST', + dataType: 'json', + crossDomain: true, + data: JSON.stringify(payload), + contentType: 'application/json', + success: resolve, + error: (_xhr, _status, err) => reject(err || new Error(`request failed: ${url}`)) + }); + }); + } + + throw new Error(`no http client available for ${url}`); +} + +function defaultBrowserDeps() { + return { + async readSelectedRange() { + if (Array.isArray(globalThis.mac?.dayRange) && globalThis.mac.dayRange.length >= 2) { + return { + start: pickFirstNonEmpty(globalThis.mac.dayRange[0]), + end: pickFirstNonEmpty(globalThis.mac.dayRange[1]) + }; + } + + const startInput = globalThis.document?.querySelector?.('input[placeholder*="开始"], input[placeholder*="start"]'); + const endInput = globalThis.document?.querySelector?.('input[placeholder*="结束"], input[placeholder*="end"]'); + const start = pickFirstNonEmpty(startInput?.value); + const end = pickFirstNonEmpty(endInput?.value); + if (start && end) { + return { start, end }; + } + return null; + }, + + readCompanyContext() { + return { + companyName: pickFirstNonEmpty(globalThis.mac?.infoObj?.cityName, globalThis.cityName) + }; + }, + + async queryFaultRows() { + if (Array.isArray(globalThis.__faultDetailsRawRows)) { + return globalThis.__faultDetailsRawRows; + } + if (Array.isArray(globalThis.mac?.rawFaultRows)) { + return globalThis.mac.rawFaultRows; + } + if (Array.isArray(globalThis.mac?.fdList)) { + return globalThis.mac.fdList; + } + throw new Error('fault_row_query_unavailable'); + }, + + async exportWorkbook(payload) { + const result = await postJson( + 'http://localhost:13313/SurfaceServices/personalBread/export/faultDetailsExportXLSXS', + payload + ); + return { + path: pickFirstNonEmpty(result?.data?.data, result?.data?.path, result?.path) + }; + }, + + async writeReportLog(reportName, path) { + const account = pickFirstNonEmpty(globalThis.localStorage?.userinfo ? JSON.parse(globalThis.localStorage.userinfo).account : ''); + const payload = { + account, + type: 'fault-details-report', + reportName, + reportInfo: '', + path + }; + const result = await postJson('http://localhost:13313/ReportServices/Api/setReportLog', payload); + return { + success: result?.msg === 'success' || result?.success === true || result?.code === 200, + raw: result + }; + } + }; +} + +function buildReportName(context, selectedRange) { + const companyName = pickFirstNonEmpty(context.companyName, '国网甘肃供电公司'); + const end = pickFirstNonEmpty(selectedRange?.end); + const reportDay = end ? end.slice(5, 10).replace('-', '月') + '日' : ''; + return `${companyName}故障报修明细表(${reportDay})`; +} + +async function buildBrowserEntrypointResult(input = {}, deps = defaultBrowserDeps()) { + const period = pickFirstNonEmpty(input.period); + const partialReasons = []; + let blockedReason = ''; + let fatalError = ''; + let selectedRange; + let detailRows = []; + let summaryRows = []; + let downstream; + + try { + const context = deps.readCompanyContext ? await deps.readCompanyContext(input) : {}; + selectedRange = deps.readSelectedRange ? await deps.readSelectedRange(input, context) : null; + if (!selectedRange || !pickFirstNonEmpty(selectedRange.start) || !pickFirstNonEmpty(selectedRange.end)) { + blockedReason = 'selected_range_unavailable'; + return buildFaultDetailsArtifact({ + period, + partialReasons, + blockedReason + }); + } + + let rawRows; + try { + rawRows = deps.queryFaultRows + ? await deps.queryFaultRows({ input, selectedRange, context }) + : []; + } catch (error) { + const message = pickFirstNonEmpty(error?.message, String(error)); + if (message === 'fault_row_query_unavailable') { + blockedReason = 'fault_row_query_unavailable'; + return buildFaultDetailsArtifact({ + period, + selectedRange, + partialReasons, + blockedReason + }); + } + fatalError = 'fault_row_query_failed'; + partialReasons.push('fault_row_query_failed'); + return buildFaultDetailsArtifact({ + period, + selectedRange, + partialReasons, + fatalError + }); + } + + const filteredRows = Array.isArray(rawRows) + ? rawRows.filter((item) => item?.statusName !== '回退营销') + : []; + if (!Array.isArray(rawRows)) { + fatalError = 'fault_row_payload_invalid'; + partialReasons.push('fault_row_payload_invalid'); + return buildFaultDetailsArtifact({ + period, + selectedRange, + partialReasons, + fatalError + }); + } + + let invalidCount = 0; + detailRows = filteredRows + .map((row) => { + const normalized = normalizeDetailRow(row, context); + if (!normalized) { + invalidCount += 1; + } + return normalized; + }) + .filter(Boolean); + + if (filteredRows.length > 0 && detailRows.length === 0) { + fatalError = 'detail_normalization_failed'; + partialReasons.push('detail_normalization_failed'); + return buildFaultDetailsArtifact({ + period, + selectedRange, + partialReasons, + fatalError, + detailRows, + summaryRows + }); + } + if (invalidCount > 0) { + partialReasons.push('detail_normalization_partial'); + } + + try { + summaryRows = deriveSummaryRows(detailRows, context); + } catch (_error) { + if (detailRows.length === 0) { + fatalError = 'summary_derivation_failed'; + } else { + partialReasons.push('summary_derivation_failed'); + summaryRows = []; + } + return buildFaultDetailsArtifact({ + period, + selectedRange, + detailRows, + summaryRows, + partialReasons, + fatalError + }); + } + + if (deps.exportWorkbook) { + downstream = { + export: { attempted: true, success: false }, + report_log: { attempted: false, success: false } + }; + try { + const exportPayload = buildExportPayload({ detailRows, summaryRows, context, selectedRange }); + const exportResult = await deps.exportWorkbook(exportPayload, { input, selectedRange, context, detailRows, summaryRows }); + downstream.export.success = true; + downstream.export.path = pickFirstNonEmpty(exportResult?.path); + + if (deps.writeReportLog) { + downstream.report_log.attempted = true; + try { + const reportName = buildReportName(context, selectedRange); + const reportLogResult = await deps.writeReportLog(reportName, downstream.export.path, { + input, + selectedRange, + context, + detailRows, + summaryRows + }); + downstream.report_log.success = reportLogResult?.success !== false; + downstream.report_log.report_name = reportName; + downstream.report_log.path = downstream.export.path; + if (!downstream.report_log.success) { + partialReasons.push('report_log_failed'); + downstream.report_log.error = pickFirstNonEmpty(reportLogResult?.error, 'report_log_failed'); + } + } catch (error) { + partialReasons.push('report_log_failed'); + downstream.report_log.success = false; + downstream.report_log.error = pickFirstNonEmpty(error?.message, String(error)); + } + } + } catch (error) { + partialReasons.push('export_failed'); + downstream.export.success = false; + downstream.export.error = pickFirstNonEmpty(error?.message, String(error)); + } + } + + return buildFaultDetailsArtifact({ + period, + selectedRange, + detailRows, + summaryRows, + partialReasons, + downstream + }); + } catch (error) { + fatalError = pickFirstNonEmpty(error?.message, 'fault_details_unhandled_error'); + if (!partialReasons.includes(fatalError)) { + partialReasons.push(fatalError); + } + return buildFaultDetailsArtifact({ + period, + selectedRange, + detailRows, + summaryRows, + partialReasons, + fatalError + }); + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + DETAIL_COLUMNS, + SUMMARY_COLUMNS, + CLASSIFICATION_RULES, + extractFaultReason, + normalizeDetailRow, + deriveSummaryRows, + determineArtifactStatus, + buildFaultDetailsArtifact, + buildExportPayload, + buildBrowserEntrypointResult + }; +} else { + return buildBrowserEntrypointResult(args); +} diff --git a/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js b/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js new file mode 100644 index 0000000..4ef0172 --- /dev/null +++ b/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js @@ -0,0 +1,287 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DETAIL_COLUMNS, + SUMMARY_COLUMNS, + normalizeDetailRow, + deriveSummaryRows, + determineArtifactStatus, + buildFaultDetailsArtifact, + buildBrowserEntrypointResult +} = require('./collect_fault_details.js'); + +test('exports canonical detail and summary columns', () => { + assert.ok(Array.isArray(DETAIL_COLUMNS)); + assert.ok(Array.isArray(SUMMARY_COLUMNS)); + assert.ok(DETAIL_COLUMNS.includes('qxdbh')); + assert.ok(DETAIL_COLUMNS.includes('gzyy')); + assert.ok(SUMMARY_COLUMNS.includes('allCount')); + assert.ok(SUMMARY_COLUMNS.includes('qftdCount')); +}); + +test('normalizeDetailRow maps canonical detail fields from raw repair rows', () => { + const row = normalizeDetailRow( + { + qxdbh: 'QX-1', + bxsj: '2026-03-09 08:00:00', + cityName: '国网兰州供电公司', + maintOrgName: '城关供电服务班', + maintGroupName: '抢修一班', + bdzMc: '110kV东岗变', + xlmc10: '10kV东岗线', + byqmc: '东岗1号变', + yjflMc: '电网故障', + ejflMc: '线路故障', + sjflMc: '低压线路', + qxxcjl: '现场检查:低压线路断线,已处理完成', + gzms: '客户报修停电' + }, + { + companyName: '国网兰州供电公司' + } + ); + + assert.equal(row.slsj, '2026-03-09 08:00:00'); + assert.equal(row.gssgs, '甘肃省电力公司'); + assert.equal(row.gddw, '城关供电服务班'); + assert.equal(row.gds, '抢修一班'); + assert.equal(row.clzt, '处理完成'); + assert.equal(row.bdz, '110kV东岗变'); + assert.equal(row.line, '10kV东岗线'); + assert.equal(row.pb, '东岗1号变'); +}); + +test('deriveSummaryRows groups normalized rows by gds and computes counters', () => { + const rows = [ + { + gds: '抢修一班', + gddw: '城关供电服务班', + sgs: '国网兰州供电公司', + sxfl1: '无效', + sxfl2: '无效', + gzsb: '' + }, + { + gds: '抢修一班', + gddw: '城关供电服务班', + sgs: '国网兰州供电公司', + sxfl1: '有效', + sxfl2: '用户侧', + gzsb: '表后线' + }, + { + gds: '抢修一班', + gddw: '城关供电服务班', + sgs: '国网兰州供电公司', + sxfl1: '有效', + sxfl2: '电网侧', + dwcFl: '低压故障', + gzsb: '低压线路' + } + ]; + + const summaryRows = deriveSummaryRows(rows, { companyName: '国网兰州供电公司' }); + assert.equal(summaryRows.length, 1); + assert.equal(summaryRows[0].className, '抢修一班'); + assert.equal(summaryRows[0].allCount, 2); + assert.equal(summaryRows[0].wxCount, 1); + assert.equal(summaryRows[0].khcCount, 0); + assert.equal(summaryRows[0].dyGzCount, 1); + assert.equal(summaryRows[0].dyxlCount, 1); + assert.equal(summaryRows[0].bhxCount, 1); +}); + +test('determineArtifactStatus follows blocked > error > partial > empty > ok precedence', () => { + assert.equal( + determineArtifactStatus({ + blockedReason: 'missing_session', + fatalError: null, + partialReasons: [], + detailRows: [{}] + }), + 'blocked' + ); + assert.equal( + determineArtifactStatus({ + blockedReason: null, + fatalError: 'parse_failed', + partialReasons: [], + detailRows: [{}] + }), + 'error' + ); + assert.equal( + determineArtifactStatus({ + blockedReason: null, + fatalError: null, + partialReasons: ['export_failed'], + detailRows: [{}] + }), + 'partial' + ); + assert.equal( + determineArtifactStatus({ + blockedReason: null, + fatalError: null, + partialReasons: [], + detailRows: [] + }), + 'empty' + ); + assert.equal( + determineArtifactStatus({ + blockedReason: null, + fatalError: null, + partialReasons: [], + detailRows: [{}] + }), + 'ok' + ); +}); + +test('buildFaultDetailsArtifact keeps canonical fields, selected range, counts, and downstream results', () => { + const artifact = buildFaultDetailsArtifact({ + period: '2026-03', + selectedRange: { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }, + detailRows: [{ qxdbh: 'QX-1' }], + summaryRows: [{ index: 1 }], + partialReasons: ['report_log_failed'], + downstream: { + export: { attempted: true, success: true, path: 'http://localhost/export.xlsx' }, + report_log: { attempted: true, success: false, error: '500' } + } + }); + + assert.equal(artifact.type, 'report-artifact'); + assert.equal(artifact.status, 'partial'); + assert.deepEqual(artifact.selected_range, { + start: '2026-03-08 16:00:00', + end: '2026-03-09 16:00:00' + }); + assert.equal(artifact.counts.detail_rows, 1); + assert.equal(artifact.counts.summary_rows, 1); + assert.deepEqual(artifact.partial_reasons, ['report_log_failed']); +}); + +test('buildFaultDetailsArtifact keeps required top-level fields for blocked artifact', () => { + const artifact = buildFaultDetailsArtifact({ + period: '2026-03', + blockedReason: 'selected_range_unavailable', + partialReasons: ['selected_range_unavailable'] + }); + + assert.equal(artifact.type, 'report-artifact'); + assert.equal(artifact.report_name, 'fault-details-report'); + assert.equal(artifact.period, '2026-03'); + assert.equal(artifact.status, 'blocked'); + assert.deepEqual(artifact.partial_reasons, ['selected_range_unavailable']); + assert.equal('downstream' in artifact, false); +}); + +test('buildFaultDetailsArtifact keeps known selected range and counts on late error', () => { + const artifact = buildFaultDetailsArtifact({ + period: '2026-03', + selectedRange: { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }, + detailRows: [], + summaryRows: [], + fatalError: 'summary_failed', + partialReasons: ['summary_failed'] + }); + + assert.equal(artifact.status, 'error'); + assert.deepEqual(artifact.selected_range, { + start: '2026-03-08 16:00:00', + end: '2026-03-09 16:00:00' + }); + assert.equal(artifact.counts.detail_rows, 0); + assert.equal(artifact.counts.summary_rows, 0); +}); + +test('normalizeDetailRow derives gzyy from qxxcjl text heuristics', () => { + const row = normalizeDetailRow( + { + qxdbh: 'QX-2', + bxsj: '2026-03-09 09:00:00', + ejflMc: '客户侧故障', + sjflMc: '表后线', + qxxcjl: '现场检查:客户表后线烧损,已恢复送电' + }, + { companyName: '国网兰州供电公司' } + ); + + assert.equal(row.gzsb, '表后线'); + assert.equal(row.gzyy, '表后线烧损'); +}); + +test('buildBrowserEntrypointResult returns partial when export fails after detail collection succeeds', async () => { + const artifact = await buildBrowserEntrypointResult( + { period: '2026-03' }, + { + readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }), + queryFaultRows: async () => [ + { + qxdbh: 'QX-1', + bxsj: '2026-03-09 08:00:00', + maintGroupName: '抢修一班', + maintOrgName: '城关供电服务班', + cityName: '国网兰州供电公司' + } + ], + readCompanyContext: () => ({ companyName: '国网兰州供电公司' }), + exportWorkbook: async () => { + throw new Error('export_failed'); + }, + writeReportLog: async () => ({ success: true }) + } + ); + + assert.equal(artifact.status, 'partial'); + assert.ok(artifact.partial_reasons.includes('export_failed')); + assert.equal(artifact.counts.detail_rows, 1); + assert.equal(artifact.downstream.export.attempted, true); + assert.equal(artifact.downstream.export.success, false); +}); + +test('buildBrowserEntrypointResult returns error when normalized detail rows cannot be produced', async () => { + const artifact = await buildBrowserEntrypointResult( + { period: '2026-03' }, + { + readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }), + queryFaultRows: async () => [{ qxdbh: '', bxsj: '' }], + readCompanyContext: () => ({ companyName: '国网兰州供电公司' }) + } + ); + + assert.equal(artifact.status, 'error'); + assert.ok(artifact.partial_reasons.includes('detail_normalization_failed')); +}); + +test('buildBrowserEntrypointResult keeps canonical rows empty for empty result and omits downstream before attempts', async () => { + const artifact = await buildBrowserEntrypointResult( + { period: '2026-03' }, + { + readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }), + queryFaultRows: async () => [], + readCompanyContext: () => ({ companyName: '国网兰州供电公司' }) + } + ); + + assert.equal(artifact.status, 'empty'); + assert.deepEqual(artifact.rows, []); + assert.equal('downstream' in artifact, false); +}); + +test('buildBrowserEntrypointResult returns blocked artifact when selected range is unavailable', async () => { + const artifact = await buildBrowserEntrypointResult( + { + period: '2026-03' + }, + { + readSelectedRange: async () => null + } + ); + + assert.equal(artifact.status, 'blocked'); + assert.ok(artifact.partial_reasons.includes('selected_range_unavailable')); +});