git mv expected_undelivered.py -> compare.py; update the 3 importers (store/runtime/router) to import compare. All public names (main, write_site_file, _read_business_dates) unchanged. Also add the Tier 2 plan doc. Co-Authored-By: Claude <noreply@anthropic.com>
19 KiB
Tier 2: domain extract + rename + path dedup — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Three targeted, behavior-preserving cleanups of the compare/config layer: (1) make expected_undelivered use paths.DOWNLOAD_DIR/OUTPUT_DIR instead of its own duplicate anchor; (2) extract shared site/file/column config into a new domain module; (3) rename expected_undelivered → compare.
Architecture: Pure refactor — move definitions, rewire imports, no logic change. Removes the duplicate path anchor that caused the Tier 1 hotfix bug (17293be), and the db_store → expected_undelivered coupling where the DB layer imported the whole compare engine just to read site config.
Tech Stack: Python ≥3.10, package inbound_verify, pandas, openpyxl, psycopg3.
Global Constraints
- Python ≥ 3.10, package import name
inbound_verify. - No behavior change — pure refactor. Any logic change is a defect.
- No test suite (user decision). Per-task verification =
compileall+ import smoke (fresh process, no backend needed) + grep-for-stale-refs. NOT pytest. (The running backend is unaffected until a restart; a final end-to-end re-confirm is done once at the end of Tier 2.) - No auto-commit (project rule). Each task's commit step runs only after the user says "提交". Commit messages in English.
- Black-format every changed
.py(global rule). - All changes inside the
InboundVerifysubmodule; commits local ondev. - Venv interpreter:
.venv/Scripts/python.exe(absolute:D:/projects/LogisticsHubIPA/InboundVerify/.venv/Scripts/python.exe). Run commands fromD:/projects/LogisticsHubIPA/InboundVerify/.
Reference spec: docs/superpowers/specs/2026-07-23-package-restructure-design.md §8 Tier 2.
Out of scope (deferred): config.py centralization (consolidating the 6 open(CONFIG_PATH)+yaml.safe_load sites in store/runtime×2/anneng/router). It is the churniest Tier 2 item (6 files) for the least marginal value — pure DRY, no pain addressed — and under no-tests more churn = more risk. Reconsider after the rest of Tier 2 lands.
File Structure
inbound_verify/
├── domain.py # NEW (Task 2): shared site/file/column config — leaf module
├── paths.py # unchanged (already the single anchor)
├── expected_undelivered.py# Task 1: use paths.* ; Task 2: import config from domain ; Task 3: renamed → compare.py
├── compare.py # (after Task 3) was expected_undelivered.py — compare engine + report
├── store.py # Task 2: import config from domain (not eu); Task 3: import _read_business_dates from compare
├── runtime.py # Task 2: SITE_UNDELIVERED_FILE from domain; Task 3: write_site_file from compare
└── cli/router.py # Task 3: compare.main()
domain.py responsibility: pure data — ALL_REPORT_SITES, SITE_UNDELIVERED_FILE, BAISHI_FILE, BAISHI_COLUMNS, arrived_pieces_zhongtong, arrived_pieces_by_cols, STATIONS, _site_cfg. No state_store dependency, no file I/O. Leaf module.
Task 1: Path dedup — expected_undelivered uses paths.DOWNLOAD_DIR/OUTPUT_DIR
Files:
- Modify:
inbound_verify/expected_undelivered.py(lines 43-48 defs; usages at 146,147,298,338,374,400,669,675,676,690)
Interfaces:
-
Consumes:
paths.DOWNLOAD_DIR,paths.OUTPUT_DIR(already exist, anchored at project root). -
Produces:
expected_undeliveredno longer definesBASE/DOWNLOADS/OUTPUT; keepsOUTFILE(now= join(OUTPUT_DIR, "应到未到数据.xlsx")). The 6DOWNLOADSand 1OUTPUTusages point at thepaths.*constants — same resolved values as the current hotfixed anchor, so behavior identical. -
Step 1: Add the paths import to the top import block
In inbound_verify/expected_undelivered.py, after the existing import os (top of file), add a line importing the two path constants. (The file currently has no paths import — it used its own BASE.)
Add:
from inbound_verify.paths import DOWNLOAD_DIR, OUTPUT_DIR
(place it alongside the other imports near the top, e.g. right after import os.)
- Step 2: Replace the self-anchored BASE/DOWNLOADS/OUTPUT/OUTFILE block
Replace this block (currently lines ~43-48):
# 包内文件:上两级 = 项目根(与 paths.BASE_DIR 一致;站点下载落在 <root>/downloads)。
# 注:本模块自带锚点是 paths.py 的重复,Tier 2 计划改为直接引用 paths.DOWNLOAD_DIR/OUTPUT_DIR。
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOWNLOADS = os.path.join(BASE, "downloads")
OUTPUT = os.path.join(BASE, "output")
OUTFILE = os.path.join(OUTPUT, "应到未到数据.xlsx")
with:
# 比对报表输出文件(路径锚定统一走 paths.py)。
OUTFILE = os.path.join(OUTPUT_DIR, "应到未到数据.xlsx")
- Step 3: Replace all
DOWNLOADSusages withDOWNLOAD_DIR
Apply DOWNLOADS → DOWNLOAD_DIR (6 occurrences, at lines 146, 147, 298, 338, 669, 675, 676). Use replace-all on the token DOWNLOADS.
- Step 4: Replace the remaining
OUTPUTusage (the makedirs line)
At line ~374, replace:
os.makedirs(OUTPUT, exist_ok=True)
with:
os.makedirs(OUTPUT_DIR, exist_ok=True)
(OUTFILE at lines 400/690 is unchanged — it's a different token, already redefined in Step 2.)
- Step 5: Verify syntax + no stale BASE/DOWNLOADS/OUTPUT
cd /d/projects/LogisticsHubIPA/InboundVerify
.venv/Scripts/python.exe -m py_compile inbound_verify/expected_undelivered.py
grep -nE "\b(BASE|DOWNLOADS|OUTPUT)\b" inbound_verify/expected_undelivered.py || echo "no stale BASE/DOWNLOADS/OUTPUT OK"
Expected: py_compile silent; grep prints no stale BASE/DOWNLOADS/OUTPUT OK (OUTFILE is a different token, won't match).
- Step 6: Import smoke + paths equivalence
.venv/Scripts/python.exe -c "from inbound_verify import expected_undelivered as eu; from inbound_verify.paths import DOWNLOAD_DIR, OUTPUT_DIR; import os; print('OUTFILE dir matches OUTPUT_DIR:', os.path.dirname(eu.OUTFILE)==OUTPUT_DIR)"
.venv/Scripts/python.exe -c "import inbound_verify.cli.router, inbound_verify.cli.server, inbound_verify.store, inbound_verify.runtime; print('import smoke OK')"
Expected: OUTFILE dir matches OUTPUT_DIR: True and import smoke OK.
- Step 7: Black + commit (after user confirms)
.venv/Scripts/python.exe -m black inbound_verify/expected_undelivered.py
git add inbound_verify/expected_undelivered.py
git commit -m "refactor: expected_undelivered uses paths.DOWNLOAD_DIR/OUTPUT_DIR (Tier 2)
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 2: Extract domain.py (shared site/file/column config)
Files:
- Create:
inbound_verify/domain.py - Modify:
inbound_verify/expected_undelivered.py(remove the moved block, import from domain) - Modify:
inbound_verify/store.py(config from domain, not eu) - Modify:
inbound_verify/runtime.py(SITE_UNDELIVERED_FILE from domain)
Interfaces:
-
Consumes: nothing new (moves existing definitions verbatim).
-
Produces:
inbound_verify.domainexposingALL_REPORT_SITES,SITE_UNDELIVERED_FILE,BAISHI_FILE,BAISHI_COLUMNS,arrived_pieces_zhongtong(df),arrived_pieces_by_cols(wb_col, piece_col),STATIONS(list of dicts),_site_cfg(name). These are the exact same objects that lived inexpected_undeliveredlines 50-135. -
Step 1: Create
inbound_verify/domain.py
Create inbound_verify/domain.py with this exact content (moved verbatim from expected_undelivered.py lines 50-135, plus the defaultdict import it needs):
# -*- coding: utf-8 -*-
"""domain.py — 站点 / 文件名 / 列映射的共享配置(单一来源)。
比对(compare)与入库(store)都依赖这套配置;抽出独立 leaf 模块,
让 store 不必为读配置而依赖整个比对引擎。纯数据,无 state_store / 文件 IO 依赖。
"""
from collections import defaultdict
# 汇总报表覆盖的全部站点(4 站在前、百世在末;汇总页图表只取 4 站)
ALL_REPORT_SITES = ["顺心", "中通", "韵达", "安能", "百世"]
# 4 站单站未到明细文件名(百世未到文件由站点直接产出,名为 BAISHI_FILE)
SITE_UNDELIVERED_FILE = "{name}-未到数据.xlsx"
BAISHI_FILE = "百世-应到未到货物数据.xlsx"
BAISHI_COLUMNS = ["类型", "子单号", "运单号", "最新扫描记录"]
def arrived_pieces_zhongtong(df):
"""中通:实到「运单号」为复合串(H + 运单号(12) + 总数(4) + 顺序(4))。
基号 = v[:-8](与应到表运单号对齐),单件 = 整串(每串即一件)。"""
res = defaultdict(set)
for v in df["运单号"]:
v = str(v).strip()
if len(v) > 8 and v[-4:].isdigit():
res[v[:-8]].add(v) # 以完整复合串作为“已到单号”存入
return res
def arrived_pieces_by_cols(wb_col, piece_col):
"""顺心 / 韵达 / 安能:按干净运单列分组,单件 = 子单号 / 扫描单号。
wb_col:实到表中与应到运单号对齐的干净列
(顺心=运单号 / 韵达=主单号 / 安能=所属单号)
piece_col:实到表中每件货物的单号列(子单号 / 扫描单号)"""
def parse(df):
res = defaultdict(set)
for m, s in zip(df[wb_col], df[piece_col]):
m, s = str(m).strip(), str(s).strip()
if m and s:
res[m].add(s)
return res
return parse
STATIONS = [
{
"name": "中通",
"exp": "中通-应到货物数据.xlsx",
"act": "中通-实到货物数据.xlsx",
"exp_qty": "交接件数", # 应到件数口径:交接件数(非录单件数)
"exp_wb": "运单号", # 应到表运单号列(兼作去重键)
"exp_jd": "交接单号", # 未到数据需展示的交接单号
"arrived_pieces": arrived_pieces_zhongtong,
"columns": ["交接单号", "运单号", "总件数"],
},
{
"name": "顺心",
"exp": "顺心-应到货物数据.xlsx",
"act": "顺心-实到货物数据.xlsx",
"exp_qty": "交接件数",
"exp_wb": "运单号",
"exp_jd": "交接单号",
"arrived_pieces": arrived_pieces_by_cols("运单号", "子单号"),
"columns": ["交接单号", "运单号", "总件数"],
},
{
"name": "韵达",
"exp": "韵达-应到货物数据.xlsx",
"act": "韵达-实到货物数据.xlsx",
"exp_qty": "交接件数",
"exp_wb": "运单号",
"exp_jd": "交接单号",
"arrived_pieces": arrived_pieces_by_cols("主单号", "子单号"),
"columns": ["交接单号", "运单号", "总件数"],
},
{
"name": "安能",
"exp": "安能-应到货物数据.xlsx",
"act": "安能-实到货物数据.xlsx",
"exp_qty": "交接件数",
"exp_wb": "运单号",
"exp_jd": "交接单号",
"arrived_pieces": arrived_pieces_by_cols("所属单号", "扫描单号"),
"columns": ["交接单号", "运单号", "总件数"],
},
]
def _site_cfg(name):
"""按名称取 4 站配置(百世不在 STATIONS,返回 None)。"""
return next((c for c in STATIONS if c["name"] == name), None)
- Step 2: Remove the moved block from expected_undelivered.py and import from domain
In inbound_verify/expected_undelivered.py:
- Add to the top import block:
from inbound_verify.domain import (
ALL_REPORT_SITES,
BAISHI_COLUMNS,
BAISHI_FILE,
SITE_UNDELIVERED_FILE,
STATIONS,
_site_cfg,
arrived_pieces_by_cols,
arrived_pieces_zhongtong,
)
-
Delete the now-duplicated definitions (the block from
ALL_REPORT_SITES = ...through the end of_site_cfg, i.e. old lines ~50-135 — the comment header# 汇总报表...throughreturn next(...)). These now live in domain.py. Thefrom collections import defaultdictimport in expected_undelivered.py can stay (harmless) or be removed if unused — check with grep after. -
Step 3: store.py — take config from domain, _read_business_dates still from eu
In inbound_verify/store.py:
- Add to imports:
from inbound_verify.domain import BAISHI_FILE, _site_cfg
-
Replace
cfg = eu._site_cfg(site)(2 occurrences, lines 264 and 296) →cfg = _site_cfg(site). -
Replace
eu.BAISHI_FILE(2 occurrences, lines 335 and 337) →BAISHI_FILE. -
Leave
eu._read_business_dates(...)(line 213) unchanged — that's compare behavior, stays accessed via the compare module (eu). Thefrom inbound_verify import expected_undelivered as euimport stays for this. -
Step 4: runtime.py — SITE_UNDELIVERED_FILE from domain
In inbound_verify/runtime.py:
- Add to imports:
from inbound_verify.domain import SITE_UNDELIVERED_FILE
- Replace (line ~448):
DOWNLOAD_DIR, expected_undelivered.SITE_UNDELIVERED_FILE.format(name=site)
with:
DOWNLOAD_DIR, SITE_UNDELIVERED_FILE.format(name=site)
-
Leave
expected_undelivered.write_site_file(site)(line ~446) andexpected_undelivered.main()(line ~476) unchanged (compare behavior). -
Step 5: Verify — compileall + import smoke + domain is leaf
cd /d/projects/LogisticsHubIPA/InboundVerify
.venv/Scripts/python.exe -m compileall -q inbound_verify
.venv/Scripts/python.exe -c "import inbound_verify.domain as d; print('STATIONS:', [s['name'] for s in d.STATIONS]); print('_site_cfg(中通):', d._site_cfg('中通')['exp']); print('BAISHI_FILE:', d.BAISHI_FILE)"
.venv/Scripts/python.exe -c "import inbound_verify.cli.router, inbound_verify.cli.server, inbound_verify.store, inbound_verify.runtime, inbound_verify.expected_undelivered; print('import smoke OK')"
Expected: STATIONS lists ['中通', '顺心', '韵达', '安能']; _site_cfg('中通')['exp'] = 中通-应到货物数据.xlsx; BAISHI_FILE = 百世-应到未到货物数据.xlsx; import smoke OK.
- Step 6: Black + commit (after user confirms)
.venv/Scripts/python.exe -m black inbound_verify/domain.py inbound_verify/expected_undelivered.py inbound_verify/store.py inbound_verify/runtime.py
git add inbound_verify/domain.py inbound_verify/expected_undelivered.py inbound_verify/store.py inbound_verify/runtime.py
git commit -m "refactor: extract domain.py (shared site/file/colmap config) from expected_undelivered
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 3: Rename expected_undelivered.py → compare.py
Files:
- Move:
inbound_verify/expected_undelivered.py→inbound_verify/compare.py(git mv) - Modify:
inbound_verify/store.py,inbound_verify/runtime.py,inbound_verify/cli/router.py(import + call-site rewrites)
Interfaces:
-
Consumes: Task 2's
domain(compare still uses it). -
Produces: module is
inbound_verify.compare; all public names (main,write_site_file,_read_business_dates) unchanged.expected_undeliveredno longer exists as a module name. -
Step 1: git mv the module (history preserved)
cd /d/projects/LogisticsHubIPA/InboundVerify
git mv inbound_verify/expected_undelivered.py inbound_verify/compare.py
- Step 2: store.py — import compare instead of expected_undelivered
In inbound_verify/store.py, replace:
from inbound_verify import expected_undelivered as eu
with:
from inbound_verify import compare
and replace the one call site (line ~213):
return eu._read_business_dates(ALL_SITES + ["百世"]) or {}
with:
return compare._read_business_dates(ALL_SITES + ["百世"]) or {}
(_site_cfg and BAISHI_FILE already come from domain after Task 2 — no change there.)
- Step 3: runtime.py — import compare, fix write_site_file/main
In inbound_verify/runtime.py, replace:
from inbound_verify import expected_undelivered
with:
from inbound_verify import compare
Replace expected_undelivered.write_site_file(site) (line ~446) → compare.write_site_file(site).
Replace (expected_undelivered.main() or True) (line ~476) → (compare.main() or True).
- Step 4: cli/router.py — import compare, fix main()
In inbound_verify/cli/router.py, replace:
from inbound_verify import expected_undelivered
with:
from inbound_verify import compare
Replace expected_undelivered.main() (line ~34, inside run_undelivered_compare) → compare.main().
- Step 5: Verify — no stale references + import smoke
cd /d/projects/LogisticsHubIPA/InboundVerify
.venv/Scripts/python.exe -m compileall -q inbound_verify
grep -rn "expected_undelivered" inbound_verify || echo "no stale expected_undelivered refs OK"
.venv/Scripts/python.exe -c "import inbound_verify.compare, inbound_verify.cli.router, inbound_verify.cli.server, inbound_verify.store, inbound_verify.runtime; print('import smoke OK')"
Expected: compileall silent; grep prints no stale expected_undelivered refs OK; import smoke OK.
- Step 6: Black + commit (after user confirms)
.venv/Scripts/python.exe -m black inbound_verify
git add -A inbound_verify
git commit -m "refactor: rename expected_undelivered to compare
Co-Authored-By: Claude <noreply@anthropic.com>"
Final end-to-end re-confirm (once, after all 3 tasks; requires backend restart + re-login)
After Task 3 commits, the running backend still has the old modules loaded. To confirm end-to-end behavior is unchanged on the refactored code:
- Restart backend (TaskStop current → clean ms-playwright/anneng orphans →
python -m inbound_verify.cli.server), re-login all sites. - Re-run the gate from the Tier 1 manual test: trigger
undeliveredfor 顺心/中通/韵达/安能 via API → expect 4× success + 4×*-未到数据.xlsx; trigger__compare__→ expectoutput/应到未到数据.xlsx; runpython -m inbound_verify.store ingest→ expect rows UPSERTed. All green = Tier 2 behavior-identical, done.
If you want to skip the re-login cost: the per-task import-smoke gates already prove the import graph is correct and the changes are behavior-preserving moves. The e2e re-confirm is belt-and-suspenders.
Self-Review (completed)
- Spec coverage: spec §8 Tier 2 — path dedup → Task 1; domain extract → Task 2; rename → compare → Task 3. config.py explicitly deferred (noted with rationale). All spec items addressed or consciously deferred.
- Placeholder scan: none — every step has exact code or exact commands with expected output. The domain.py content is the verbatim extracted block.
- Type/name consistency:
_site_cfg,BAISHI_FILE,SITE_UNDELIVERED_FILE,STATIONS,write_site_file,main,_read_business_datesreferenced consistently across tasks. Task 2 routes config todomainand leaves behavior (_read_business_dates,write_site_file,main) in compare — verified against store.py/runtime.py/router.py usages. Task 3 renames the module but preserves all public names.