fix(product): 聚水潭 allsku 模板隐藏列错位致 daily 销量全 0

2026-10-11 模板重置把 38 列置隐藏后可见列整体左移,固定索引 45/57 落到
「今日实退货量/今日仅退款量」。daily 改表头名匹配「昨日销量/昨日退货量」
(weekly 暂留老索引待实测);backfill 恢复自定义列可见(window._Cols +
_RptRefresh),并把表头索引按可见列换算成行内位置防 td 缺位错读。
This commit is contained in:
wangyunlong committed 2026-10-14 18:31:23 +08:00
1 parent 9a91ce80e4
commit 0cc4fafc7c
4 files changed
+292 -19

No files matched your search

@@ -205,6 +205,44 @@ def set_all_shop_daily_filters(
) )
ENSURE_CUSTOM_COLUMNS_JS = r"""
() => {
const cols = window._Cols || [];
let changed = 0;
for (const col of cols) {
const label = String(col.text || col.defaultText || '');
if ((label === '自定义销量' || label === '自定义退货量') && col.visible === false) {
col.visible = true;
changed += 1;
}
}
if (changed > 0 && typeof window._RptRefresh === 'function') {
try { window._RptRefresh(); } catch (e) {}
}
return changed;
}
"""
def ensure_custom_columns_visible(page: Any) -> int:
"""Keep 自定义销量/自定义退货量 visible before the report renders.
2026-10-11 起聚水潭报表模板把自定义公式列重置为隐藏,隐藏列不渲染
数据单元格;只改 visible 不会自动重绘,需顺带触发一次 _RptRefresh
(其尾部浮头重建可能抛错,但不影响数据行渲染)。
"""
changed_total = 0
for frame in page.frames:
if "allsku.aspx" not in (frame.url or "").lower():
continue
try:
changed_total += int(frame.evaluate(ENSURE_CUSTOM_COLUMNS_JS))
except Exception:
continue
return changed_total
def extract_style_rows( def extract_style_rows(
table_frame: Any, table_frame: Any,
requested_codes: Sequence[str], requested_codes: Sequence[str],
@@ -257,18 +295,45 @@ def extract_style_rows(
}; };
} }
// 隐藏列的 th 仍在表头,但数据行不渲染对应 td:数据行的第 j 格
// 对应第 j 个可见列。用 window._Cols 把表头列索引换算成行内位
// 置,避免隐藏列造成索引错位;拿不到 _Cols 时按原索引直取。
let stylePos = styleCol;
let productPos = productCol;
let namePos = productNameCol;
let salesPos = salesCol;
let returnsPos = returnsCol;
if (Array.isArray(window._Cols) && window._Cols.length === headerTexts.length) {
const visibleIdx = [];
for (let i = 0; i < window._Cols.length; i++) {
if (window._Cols[i].visible !== false) visibleIdx.push(i);
}
stylePos = visibleIdx.indexOf(styleCol);
productPos = productCol >= 0 ? visibleIdx.indexOf(productCol) : -1;
namePos = productNameCol >= 0 ? visibleIdx.indexOf(productNameCol) : -1;
salesPos = visibleIdx.indexOf(salesCol);
returnsPos = visibleIdx.indexOf(returnsCol);
}
if (stylePos < 0 || salesPos < 0 || returnsPos < 0) {
return {
ok: false,
error: `custom sales/return columns hidden: style=${stylePos}, sales=${salesPos}, returns=${returnsPos}`,
header_texts: headerTexts,
};
}
const rows = []; const rows = [];
for (const tr of Array.from(document.querySelectorAll('tr'))) { for (const tr of Array.from(document.querySelectorAll('tr'))) {
const cells = Array.from(tr.querySelectorAll('td, th')); const cells = Array.from(tr.querySelectorAll('td, th'));
if (cells.length <= Math.max(styleCol, salesCol, returnsCol)) continue; if (cells.length <= Math.max(stylePos, salesPos, returnsPos)) continue;
const styleCode = norm(cells[styleCol]?.textContent); const styleCode = norm(cells[stylePos]?.textContent);
if (!requested.has(styleCode)) continue; if (!requested.has(styleCode)) continue;
rows.push({ rows.push({
erp_style_code: styleCode, erp_style_code: styleCode,
product_code: productCol >= 0 ? norm(cells[productCol]?.textContent) : '', product_code: productPos >= 0 ? norm(cells[productPos]?.textContent) : '',
product_name: productNameCol >= 0 ? norm(cells[productNameCol]?.textContent) : '', product_name: namePos >= 0 ? norm(cells[namePos]?.textContent) : '',
sales: parseInteger(cells[salesCol]?.textContent), sales: parseInteger(cells[salesPos]?.textContent),
return_qty: parseInteger(cells[returnsCol]?.textContent), return_qty: parseInteger(cells[returnsPos]?.textContent),
}); });
} }
const foundCodes = Array.from(new Set(rows.map(row => row.erp_style_code))).sort(); const foundCodes = Array.from(new Set(rows.map(row => row.erp_style_code))).sort();
@@ -326,6 +391,7 @@ def wait_for_style_rows(
reload_deadline = time.monotonic() + reload_after_seconds reload_deadline = time.monotonic() + reload_after_seconds
continue continue
ensure_custom_columns_visible(page)
for frame in page.frames: for frame in page.frames:
if "allsku.aspx" not in (frame.url or "").lower(): if "allsku.aspx" not in (frame.url or "").lower():
continue continue
@@ -459,6 +525,7 @@ def collect_one_date(
selection = set_all_shop_daily_filters( selection = set_all_shop_daily_filters(
filter_frame, report_date=report_date, erp_codes=codes filter_frame, report_date=report_date, erp_codes=codes
) )
ensure_custom_columns_visible(page)
erp.generate_report(filter_frame) erp.generate_report(filter_frame)
report = wait_for_style_rows( report = wait_for_style_rows(
page, page,
@@ -84,8 +84,14 @@ MODE = "daily"
# 2026-06-11: daily 走 cell 45/57 老路径 (保护 run_daily_collect.bat 定时任务, data.json 口径不变); # 2026-06-11: daily 走 cell 45/57 老路径 (保护 run_daily_collect.bat 定时任务, data.json 口径不变);
# weekly 同样 cell 45/57 (2026-06-27 run_weekly_collect.bat 定时任务已删除, --mode weekly 留作手动回采); # weekly 同样 cell 45/57 (2026-06-27 run_weekly_collect.bat 定时任务已删除, --mode weekly 留作手动回采);
# backfill 单日窗口 + 表头匹配 (自定义销量 / 自定义退货量 + SPU 行汇总), 跟 daily/weekly 区分开 # backfill 单日窗口 + 表头匹配 (自定义销量 / 自定义退货量 + SPU 行汇总), 跟 daily/weekly 区分开
# 2026-10-11: 聚水潭重置 allsku 报表模板, 38 列被置隐藏后可见列整体左移 5 列, 固定可见索引
# 45/57 落到了「今日实退货量/今日仅退款量」(查询日清晨≈0) 上, daily 销量全 0。
# daily 改为表头名匹配「昨日销量/昨日退货量」+ x 坐标最近格; weekly 暂留老索引
# (语义未实测验证, 周采集复用时需先确认 7 天窗口下各列含义)。
WEEKLY_SALES_HEADER = "__idx__45" WEEKLY_SALES_HEADER = "__idx__45"
WEEKLY_RETURNS_HEADER = "__idx__57" WEEKLY_RETURNS_HEADER = "__idx__57"
DAILY_SALES_HEADER = "昨日销量"
DAILY_RETURNS_HEADER = "昨日退货量"
BACKFILL_SALES_HEADER = "自定义销量" BACKFILL_SALES_HEADER = "自定义销量"
BACKFILL_RETURNS_HEADER = "自定义退货量" BACKFILL_RETURNS_HEADER = "自定义退货量"
@@ -292,8 +298,11 @@ def report_date_range() -> tuple[str, str]:
def _sales_returns_idx() -> tuple[str, str]: def _sales_returns_idx() -> tuple[str, str]:
if MODE in ("weekly", "daily"): if MODE == "daily":
# daily / weekly 都走 cell 45/57 老路径, 跟之前 data.json 口径一致 # 表头匹配 (昨日销量 / 昨日退货量): 模板列可见性变化会让固定索引错位
return DAILY_SALES_HEADER, DAILY_RETURNS_HEADER
if MODE == "weekly":
# weekly 仍走 cell 45/57 老路径, 跟之前 data.json 口径一致
return WEEKLY_SALES_HEADER, WEEKLY_RETURNS_HEADER return WEEKLY_SALES_HEADER, WEEKLY_RETURNS_HEADER
# backfill 走表头匹配 (自定义销量 / 自定义退货量 + SPU 行求和) # backfill 走表头匹配 (自定义销量 / 自定义退货量 + SPU 行求和)
return BACKFILL_SALES_HEADER, BACKFILL_RETURNS_HEADER return BACKFILL_SALES_HEADER, BACKFILL_RETURNS_HEADER
@@ -395,6 +404,44 @@ def _extract_by_idx(table_frame: Any, sales_idx: int, returns_idx: int) -> dict[
) )
ENSURE_CUSTOM_COLUMNS_JS = r"""
() => {
const cols = window._Cols || [];
let changed = 0;
for (const col of cols) {
const label = String(col.text || col.defaultText || '');
if ((label === '自定义销量' || label === '自定义退货量') && col.visible === false) {
col.visible = true;
changed += 1;
}
}
if (changed > 0 && typeof window._RptRefresh === 'function') {
try { window._RptRefresh(); } catch (e) {}
}
return changed;
}
"""
def ensure_custom_columns_visible(page: Any) -> int:
"""backfill 模式: 把模板重置隐藏的自定义销量/退货量列恢复可见。
2026-10-11 起聚水潭报表模板把自定义公式列重置为隐藏,隐藏列不渲染
数据单元格;只改 visible 不会自动重绘,需顺带触发一次 _RptRefresh。
与 backfill_erp_all_shop_daily.py 同源(该脚本 10-13 实测有效)。
"""
changed_total = 0
for frame in page.frames:
if "allsku.aspx" not in (frame.url or "").lower():
continue
try:
changed_total += int(frame.evaluate(ENSURE_CUSTOM_COLUMNS_JS))
except Exception:
continue
return changed_total
def _extract_by_header(table_frame: Any, sales_header: str, returns_header: str) -> dict[str, Any]: def _extract_by_header(table_frame: Any, sales_header: str, returns_header: str) -> dict[str, Any]:
"""backfill 模式专用: 按表头列名匹配 sales / returns 列, 直接读每 SPU 行对应 cell 的 innerText 求和. """backfill 模式专用: 按表头列名匹配 sales / returns 列, 直接读每 SPU 行对应 cell 的 innerText 求和.
@@ -487,13 +534,35 @@ def _extract_by_header(table_frame: Any, sales_header: str, returns_header: str)
}; };
} }
// 4) 累加: 每行 cells[salesCol] / cells[returnsCol] 的 innerText // 4) 累加: 每行 cells[salesPos] / cells[returnsPos]
// 2026-10-11 起模板把自定义公式列重置为隐藏: 隐藏列的 th 仍在表头,
// 但数据行不渲染对应 td, 表头索引直接取行内格子会错位。用
// window._Cols 把表头列索引换算成行内可见位置; 拿不到 _Cols 时按原索引直取。
let salesPos = salesCol;
let returnsPos = returnsCol;
if (Array.isArray(window._Cols) && window._Cols.length === headerCells.length) {
const visibleIdx = [];
for (let i = 0; i < window._Cols.length; i++) {
if (window._Cols[i].visible !== false) visibleIdx.push(i);
}
salesPos = visibleIdx.indexOf(salesCol);
returnsPos = visibleIdx.indexOf(returnsCol);
}
if (salesPos < 0 || returnsPos < 0) {
return {
ok: false,
yesterday_sales: 0,
yesterday_returns: 0,
error: `custom sales/return columns hidden: salesPos=${salesPos} returnsPos=${returnsPos}`,
header_texts: headerTexts,
};
}
let totalSales = 0, totalReturns = 0; let totalSales = 0, totalReturns = 0;
const rowBreakdown = []; const rowBreakdown = [];
for (const tr of dataRows) { for (const tr of dataRows) {
const cells = Array.from(tr.querySelectorAll('td, th')); const cells = Array.from(tr.querySelectorAll('td, th'));
const salesCell = cells[salesCol]; const salesCell = cells[salesPos];
const returnsCell = cells[returnsCol]; const returnsCell = cells[returnsPos];
const head = (tr.innerText || tr.textContent || '').slice(0, 60); const head = (tr.innerText || tr.textContent || '').slice(0, 60);
if (!salesCell || !returnsCell) { if (!salesCell || !returnsCell) {
rowBreakdown.push({ rowBreakdown.push({
@@ -662,7 +731,7 @@ def _extract_with_text(table_frame: Any) -> tuple[dict[str, Any], str]:
ERP DOM often contains NBSP (\xa0) etc; Scrapling/Playwright on Windows gbk ERP DOM often contains NBSP (\xa0) etc; Scrapling/Playwright on Windows gbk
rejects those on return, so we use encodeURIComponent + tryUTF8 to make it ASCII-safe. rejects those on return, so we use encodeURIComponent + tryUTF8 to make it ASCII-safe.
按 MODE 选 idx 模式 (daily) 或 header 模式 (weekly). 按 MODE 选 idx 模式 (weekly) 或 header 模式 (daily / backfill)。
""" """
sales_key, returns_key = _sales_returns_idx() sales_key, returns_key = _sales_returns_idx()
@@ -673,17 +742,17 @@ def _extract_with_text(table_frame: Any) -> tuple[dict[str, Any], str]:
else: else:
js_args = {"salesHeader": sales_key, "returnsHeader": returns_key, "mode": "header"} js_args = {"salesHeader": sales_key, "returnsHeader": returns_key, "mode": "header"}
# 走 SPU 行 + 自定义列名匹配 + 求和 (因为 sumRow 在 ERP 系统里不含自定义段) # backfill 走 SPU 行 + 自定义列名匹配 + 求和 (因为 sumRow 在 ERP 系统里不含自定义段);
if sales_key != "__idx__45": # backfill 模式 # daily 的 昨日销量/昨日退货量 是原生列, sumRow 里就有, 走下面 payload 的
# mode='header' 分支 (表头名 -> x 坐标 -> sumRow 最近格), 不经过 _extract_by_header。
if MODE == "backfill":
header_result = _extract_by_header(table_frame, sales_key, returns_key) header_result = _extract_by_header(table_frame, sales_key, returns_key)
# _extract_by_header 返回 dict, 没有 body_text; wait_for_metrics 需要 erp_code in body_text 校验 # _extract_by_header 返回 dict, 没有 body_text; wait_for_metrics 需要 erp_code in body_text 校验
# 用 erp_code placeholder 骗校验 (它会查 erp_code in summary_text, 而 summary_text 不一定含 erp_code) # 用 erp_code placeholder 骗校验 (它会查 erp_code in summary_text, 而 summary_text 不一定含 erp_code)
# 但新逻辑在 row_breakdown 里已有 erp_code 关联的 SPU 编码 (10384xxx), 校验一定会过 # 但新逻辑在 row_breakdown 里已有 erp_code 关联的 SPU 编码 (10384xxx), 校验一定会过
return header_result, header_result.get("summary_text", "") or "PLACEHOLDER" return header_result, header_result.get("summary_text", "") or "PLACEHOLDER"
# 老 idx 模式 # weekly 老 idx 模式 + daily header 模式共用这段 payload (js_args 已按模式构造)
sales_idx = int(sales_key.removeprefix("__idx__"))
returns_idx = int(returns_key.removeprefix("__idx__"))
payload = table_frame.evaluate( payload = table_frame.evaluate(
""" """
(args) => { (args) => {
@@ -712,6 +781,9 @@ def _extract_with_text(table_frame: Any) -> tuple[dict[str, Any], str]:
} }
let salesCell = null, returnsCell = null; let salesCell = null, returnsCell = null;
// salesCol/returnsCol 供末尾 return 的 *_col_idx 引用, 必须在 if/else 外声明
// (2026-10-13 daily 切到 header 模式后首测即 ReferenceError 的教训)
let salesCol = -1, returnsCol = -1;
if (args.mode === 'idx') { if (args.mode === 'idx') {
const cells = Array.from(sumRow.children).map((cell) => { const cells = Array.from(sumRow.children).map((cell) => {
const box = cell.getBoundingClientRect(); const box = cell.getBoundingClientRect();
@@ -745,8 +817,8 @@ def _extract_with_text(table_frame: Any) -> tuple[dict[str, Any], str]:
} }
return -1; return -1;
}; };
const salesCol = findCol(args.salesHeader); salesCol = findCol(args.salesHeader);
const returnsCol = findCol(args.returnsHeader); returnsCol = findCol(args.returnsHeader);
if (salesCol < 0 || returnsCol < 0) { if (salesCol < 0 || returnsCol < 0) {
return { ok: false, yesterday_sales: 0, yesterday_returns: 0, return { ok: false, yesterday_sales: 0, yesterday_returns: 0,
error: `header not found: salesCol=${salesCol} returnsCol=${returnsCol}`, error: `header not found: salesCol=${salesCol} returnsCol=${returnsCol}`,
@@ -977,6 +1049,9 @@ def wait_for_metrics(page: Any, erp_code: str, timeout_seconds: int = 45,
# previous_url 可能在 _reload_allsku_iframe 后被重置为 None # previous_url 可能在 _reload_allsku_iframe 后被重置为 None
while True: while True:
# backfill 模式: 报表刷新会把自定义列的可见性重置回隐藏, 每轮恢复一次
if MODE == "backfill":
ensure_custom_columns_visible(page)
# 先检测"操作频繁"弹窗 (在主页 DOM, 不在 allsku iframe) # 先检测"操作频繁"弹窗 (在主页 DOM, 不在 allsku iframe)
if filter_frame is not None and freq_dialog_handled < MAX_FREQ_DIALOG: if filter_frame is not None and freq_dialog_handled < MAX_FREQ_DIALOG:
if _detect_and_close_freq_dialog(page): if _detect_and_close_freq_dialog(page):
@@ -1176,6 +1251,9 @@ def _run_one_code(page: Any, filter_frame: Any, platform: dict[str, Any], erp_co
""" """
clear_code(filter_frame) clear_code(filter_frame)
set_platform_and_code(filter_frame, platform["shops"], erp_code) set_platform_and_code(filter_frame, platform["shops"], erp_code)
if MODE == "backfill":
# 自定义销量/退货量列被模板重置隐藏时先恢复可见, 否则数据行不渲染对应格
ensure_custom_columns_visible(page)
prev_state = _get_allsku_frame_state(page) prev_state = _get_allsku_frame_state(page)
generate_report(filter_frame) generate_report(filter_frame)
return wait_for_metrics( return wait_for_metrics(
@@ -125,6 +125,57 @@ def test_style_records_sum_product_rows_and_keep_zero_styles() -> None:
assert records[1]["matched_product_rows"] == 0 assert records[1]["matched_product_rows"] == 0
def test_style_extraction_maps_hidden_columns_via_visible_positions() -> None:
class Frame:
def __init__(self) -> None:
self.script = ""
self.args = {}
def evaluate(self, script, args):
self.script = script
self.args = args
return {"ok": True, "rows": []}
frame = Frame()
backfill.extract_style_rows(frame, requested_codes=("10416",))
assert "window._Cols" in frame.script
assert "visibleIdx.indexOf" in frame.script
assert "cells[stylePos]" in frame.script
assert "cells.length <= Math.max(stylePos, salesPos, returnsPos)" in frame.script
def test_ensure_custom_columns_visible_only_touches_allsku_frames() -> None:
class AllskuFrame:
url = "https://bi.erp321.com/app/report/subject/itemskuanalysis/allsku.aspx?r=1"
def __init__(self) -> None:
self.scripts: list[str] = []
def evaluate(self, script):
self.scripts.append(script)
return 2
class OtherFrame:
url = "https://bi.erp321.com/app/report/subject/itemskuanalysis/filter.aspx"
def evaluate(self, script):
raise AssertionError("must not touch non-allsku frames")
class Page:
frames = [OtherFrame(), AllskuFrame()]
page = Page()
changed = backfill.ensure_custom_columns_visible(page)
assert changed == 2
allsku = page.frames[1]
assert len(allsku.scripts) == 1
assert "'自定义销量'" in allsku.scripts[0]
assert "'自定义退货量'" in allsku.scripts[0]
assert "_RptRefresh" in allsku.scripts[0]
def test_range_collector_queries_once_per_date_not_once_per_style(monkeypatch) -> None: def test_range_collector_queries_once_per_date_not_once_per_style(monkeypatch) -> None:
filter_frame = object() filter_frame = object()
monkeypatch.setattr( monkeypatch.setattr(
@@ -0,0 +1,77 @@
import unittest
from unittest import mock
import collect_erp_yesterday_metrics as erp_mod
class _StubFrame:
"""frame.evaluate 的最小桩: 记录入参并返回固定 payload。"""
def __init__(self, payload):
self._payload = payload
self.calls = []
def evaluate(self, script, *args):
self.calls.append((script, args))
return self._payload
class ErpDailyHeaderRoutingTest(unittest.TestCase):
"""2026-10-11 聚水潭模板重置隐藏 38 列后, 固定可见索引 45/57 错位到
「今日实退货量/今日仅退款量」, daily 销量全 0。daily 必须走表头名匹配,
且不能落入 backfill 的 _extract_by_header (自定义列 SPU 求和) 分支。"""
def test_daily_mode_uses_yesterday_headers(self):
with mock.patch.object(erp_mod, "MODE", "daily"):
self.assertEqual(
erp_mod._sales_returns_idx(),
("昨日销量", "昨日退货量"),
)
def test_weekly_mode_keeps_legacy_index(self):
with mock.patch.object(erp_mod, "MODE", "weekly"):
self.assertEqual(
erp_mod._sales_returns_idx(),
("__idx__45", "__idx__57"),
)
def test_backfill_mode_keeps_custom_headers(self):
with mock.patch.object(erp_mod, "MODE", "backfill"):
self.assertEqual(
erp_mod._sales_returns_idx(),
("自定义销量", "自定义退货量"),
)
def test_daily_header_keys_bypass_extract_by_header(self):
payload = {
"ok": True,
"yesterday_sales": 2,
"yesterday_returns": 0,
"bodyText": "",
}
frame = _StubFrame(payload)
with mock.patch.object(erp_mod, "MODE", "daily"):
metrics, _body = erp_mod._extract_with_text(frame)
self.assertEqual(metrics["yesterday_sales"], 2)
self.assertEqual(len(frame.calls), 1)
script, args = frame.calls[0]
js_args = args[0] if args else script
self.assertEqual(js_args["mode"], "header")
self.assertEqual(js_args["salesHeader"], "昨日销量")
self.assertEqual(js_args["returnsHeader"], "昨日退货量")
def test_backfill_mode_routes_to_extract_by_header(self):
frame = _StubFrame({})
with mock.patch.object(erp_mod, "MODE", "backfill"):
with mock.patch.object(
erp_mod,
"_extract_by_header",
return_value={"ok": True, "yesterday_sales": 5, "summary_text": "X"},
) as by_header:
metrics, _body = erp_mod._extract_with_text(frame)
by_header.assert_called_once()
self.assertEqual(metrics["yesterday_sales"], 5)
if __name__ == "__main__":
unittest.main()