From 0cc4fafc7ca95aea8ea027e0a64c8ab085138f74 Mon Sep 17 00:00:00 2001 From: wangyunlong <3080433063@qq.com> Date: Wed, 14 Oct 2026 18:31:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(product):=20=E8=81=9A=E6=B0=B4=E6=BD=AD=20a?= =?UTF-8?q?llsku=20=E6=A8=A1=E6=9D=BF=E9=9A=90=E8=97=8F=E5=88=97=E9=94=99?= =?UTF-8?q?=E4=BD=8D=E8=87=B4=20daily=20=E9=94=80=E9=87=8F=E5=85=A8=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-10-11 模板重置把 38 列置隐藏后可见列整体左移,固定索引 45/57 落到 「今日实退货量/今日仅退款量」。daily 改表头名匹配「昨日销量/昨日退货量」 (weekly 暂留老索引待实测);backfill 恢复自定义列可见(window._Cols + _RptRefresh),并把表头索引按可见列换算成行内位置防 td 缺位错读。 --- .../backfill_erp_all_shop_daily.py | 79 ++++++++++++- .../collect_erp_yesterday_metrics.py | 104 +++++++++++++++--- .../test_erp_all_shop_daily_backfill.py | 51 +++++++++ .../test_erp_daily_header_routing.py | 77 +++++++++++++ 4 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 tests/modules/product_commerce/test_erp_daily_header_routing.py diff --git a/src/gyxx_flow/modules/product_commerce/backfill_erp_all_shop_daily.py b/src/gyxx_flow/modules/product_commerce/backfill_erp_all_shop_daily.py index cf93f1e..fcffc7d 100644 --- a/src/gyxx_flow/modules/product_commerce/backfill_erp_all_shop_daily.py +++ b/src/gyxx_flow/modules/product_commerce/backfill_erp_all_shop_daily.py @@ -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( table_frame: Any, 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 = []; for (const tr of Array.from(document.querySelectorAll('tr'))) { const cells = Array.from(tr.querySelectorAll('td, th')); - if (cells.length <= Math.max(styleCol, salesCol, returnsCol)) continue; - const styleCode = norm(cells[styleCol]?.textContent); + if (cells.length <= Math.max(stylePos, salesPos, returnsPos)) continue; + const styleCode = norm(cells[stylePos]?.textContent); if (!requested.has(styleCode)) continue; rows.push({ erp_style_code: styleCode, - product_code: productCol >= 0 ? norm(cells[productCol]?.textContent) : '', - product_name: productNameCol >= 0 ? norm(cells[productNameCol]?.textContent) : '', - sales: parseInteger(cells[salesCol]?.textContent), - return_qty: parseInteger(cells[returnsCol]?.textContent), + product_code: productPos >= 0 ? norm(cells[productPos]?.textContent) : '', + product_name: namePos >= 0 ? norm(cells[namePos]?.textContent) : '', + sales: parseInteger(cells[salesPos]?.textContent), + return_qty: parseInteger(cells[returnsPos]?.textContent), }); } 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 continue + ensure_custom_columns_visible(page) for frame in page.frames: if "allsku.aspx" not in (frame.url or "").lower(): continue @@ -459,6 +525,7 @@ def collect_one_date( selection = set_all_shop_daily_filters( filter_frame, report_date=report_date, erp_codes=codes ) + ensure_custom_columns_visible(page) erp.generate_report(filter_frame) report = wait_for_style_rows( page, diff --git a/src/gyxx_flow/modules/product_commerce/collect_erp_yesterday_metrics.py b/src/gyxx_flow/modules/product_commerce/collect_erp_yesterday_metrics.py index 0a91cba..b3bf962 100644 --- a/src/gyxx_flow/modules/product_commerce/collect_erp_yesterday_metrics.py +++ b/src/gyxx_flow/modules/product_commerce/collect_erp_yesterday_metrics.py @@ -84,8 +84,14 @@ MODE = "daily" # 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 留作手动回采); # 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_RETURNS_HEADER = "__idx__57" +DAILY_SALES_HEADER = "昨日销量" +DAILY_RETURNS_HEADER = "昨日退货量" BACKFILL_SALES_HEADER = "自定义销量" BACKFILL_RETURNS_HEADER = "自定义退货量" @@ -292,8 +298,11 @@ def report_date_range() -> tuple[str, str]: def _sales_returns_idx() -> tuple[str, str]: - if MODE in ("weekly", "daily"): - # daily / weekly 都走 cell 45/57 老路径, 跟之前 data.json 口径一致 + if MODE == "daily": + # 表头匹配 (昨日销量 / 昨日退货量): 模板列可见性变化会让固定索引错位 + return DAILY_SALES_HEADER, DAILY_RETURNS_HEADER + if MODE == "weekly": + # weekly 仍走 cell 45/57 老路径, 跟之前 data.json 口径一致 return WEEKLY_SALES_HEADER, WEEKLY_RETURNS_HEADER # backfill 走表头匹配 (自定义销量 / 自定义退货量 + SPU 行求和) 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]: """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; const rowBreakdown = []; for (const tr of dataRows) { const cells = Array.from(tr.querySelectorAll('td, th')); - const salesCell = cells[salesCol]; - const returnsCell = cells[returnsCol]; + const salesCell = cells[salesPos]; + const returnsCell = cells[returnsPos]; const head = (tr.innerText || tr.textContent || '').slice(0, 60); if (!salesCell || !returnsCell) { 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 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() @@ -673,17 +742,17 @@ def _extract_with_text(table_frame: Any) -> tuple[dict[str, Any], str]: else: js_args = {"salesHeader": sales_key, "returnsHeader": returns_key, "mode": "header"} - # 走 SPU 行 + 自定义列名匹配 + 求和 (因为 sumRow 在 ERP 系统里不含自定义段) - if sales_key != "__idx__45": # backfill 模式 + # backfill 走 SPU 行 + 自定义列名匹配 + 求和 (因为 sumRow 在 ERP 系统里不含自定义段); + # daily 的 昨日销量/昨日退货量 是原生列, sumRow 里就有, 走下面 payload 的 + # mode='header' 分支 (表头名 -> x 坐标 -> sumRow 最近格), 不经过 _extract_by_header。 + if MODE == "backfill": 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 校验 # 用 erp_code placeholder 骗校验 (它会查 erp_code in summary_text, 而 summary_text 不一定含 erp_code) # 但新逻辑在 row_breakdown 里已有 erp_code 关联的 SPU 编码 (10384xxx), 校验一定会过 return header_result, header_result.get("summary_text", "") or "PLACEHOLDER" - # 老 idx 模式 - sales_idx = int(sales_key.removeprefix("__idx__")) - returns_idx = int(returns_key.removeprefix("__idx__")) + # weekly 老 idx 模式 + daily header 模式共用这段 payload (js_args 已按模式构造) payload = table_frame.evaluate( """ (args) => { @@ -712,6 +781,9 @@ def _extract_with_text(table_frame: Any) -> tuple[dict[str, Any], str]: } 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') { const cells = Array.from(sumRow.children).map((cell) => { const box = cell.getBoundingClientRect(); @@ -745,8 +817,8 @@ def _extract_with_text(table_frame: Any) -> tuple[dict[str, Any], str]: } return -1; }; - const salesCol = findCol(args.salesHeader); - const returnsCol = findCol(args.returnsHeader); + salesCol = findCol(args.salesHeader); + returnsCol = findCol(args.returnsHeader); if (salesCol < 0 || returnsCol < 0) { return { ok: false, yesterday_sales: 0, yesterday_returns: 0, 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 while True: + # backfill 模式: 报表刷新会把自定义列的可见性重置回隐藏, 每轮恢复一次 + if MODE == "backfill": + ensure_custom_columns_visible(page) # 先检测"操作频繁"弹窗 (在主页 DOM, 不在 allsku iframe) if filter_frame is not None and freq_dialog_handled < MAX_FREQ_DIALOG: 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) 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) generate_report(filter_frame) return wait_for_metrics( diff --git a/tests/modules/product_commerce/test_erp_all_shop_daily_backfill.py b/tests/modules/product_commerce/test_erp_all_shop_daily_backfill.py index 46509ad..f667c70 100644 --- a/tests/modules/product_commerce/test_erp_all_shop_daily_backfill.py +++ b/tests/modules/product_commerce/test_erp_all_shop_daily_backfill.py @@ -125,6 +125,57 @@ def test_style_records_sum_product_rows_and_keep_zero_styles() -> None: 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: filter_frame = object() monkeypatch.setattr( diff --git a/tests/modules/product_commerce/test_erp_daily_header_routing.py b/tests/modules/product_commerce/test_erp_daily_header_routing.py new file mode 100644 index 0000000..2e00198 --- /dev/null +++ b/tests/modules/product_commerce/test_erp_daily_header_routing.py @@ -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()