fix(product): 聚水潭 allsku 模板隐藏列错位致 daily 销量全 0
2026-10-11 模板重置把 38 列置隐藏后可见列整体左移,固定索引 45/57 落到 「今日实退货量/今日仅退款量」。daily 改表头名匹配「昨日销量/昨日退货量」 (weekly 暂留老索引待实测);backfill 恢复自定义列可见(window._Cols + _RptRefresh),并把表头索引按可见列换算成行内位置防 td 缺位错读。
This commit is contained in:
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()
|
||||||
Reference in new issue
Block a user