feat: align staged fault details artifact flow

Tighten the staged fault-details skill contract, add artifact-focused tests, and align the packaged collector with the canonical detail and summary output expected by the staged scene metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-10 18:13:50 +08:00
parent a5d31d5337
commit 7632ba519a
7 changed files with 1140 additions and 78 deletions

View File

@@ -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",

View File

@@ -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"
}
}
}
```

View File

@@ -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"

View File

@@ -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

View File

@@ -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.

View File

@@ -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]
};
}
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,
collectFaultDetails
CLASSIFICATION_RULES,
extractFaultReason,
normalizeDetailRow,
deriveSummaryRows,
determineArtifactStatus,
buildFaultDetailsArtifact,
buildExportPayload,
buildBrowserEntrypointResult
};
} else {
return buildBrowserEntrypointResult(args);
}

View File

@@ -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'));
});