# 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 `InboundVerify` submodule; commits local on `dev`. - Venv interpreter: `.venv/Scripts/python.exe` (absolute: `D:/projects/LogisticsHubIPA/InboundVerify/.venv/Scripts/python.exe`). Run commands from `D:/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_undelivered` no longer defines `BASE/DOWNLOADS/OUTPUT`; keeps `OUTFILE` (now `= join(OUTPUT_DIR, "应到未到数据.xlsx")`). The 6 `DOWNLOADS` and 1 `OUTPUT` usages point at the `paths.*` 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: ```python 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): ```python # 包内文件:上两级 = 项目根(与 paths.BASE_DIR 一致;站点下载落在 /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: ```python # 比对报表输出文件(路径锚定统一走 paths.py)。 OUTFILE = os.path.join(OUTPUT_DIR, "应到未到数据.xlsx") ``` - [ ] **Step 3: Replace all `DOWNLOADS` usages with `DOWNLOAD_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 `OUTPUT` usage (the makedirs line)** At line ~374, replace: ```python os.makedirs(OUTPUT, exist_ok=True) ``` with: ```python 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** ```bash 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** ```bash .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)** ```bash .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 " ``` --- ## 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.domain` exposing `ALL_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 in `expected_undelivered` lines 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): ```python # -*- 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: ```python 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 `# 汇总报表...` through `return next(...)`). These now live in domain.py. The `from collections import defaultdict` import 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: ```python 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`). The `from inbound_verify import expected_undelivered as eu` import stays for this. - [ ] **Step 4: runtime.py — SITE_UNDELIVERED_FILE from domain** In `inbound_verify/runtime.py`: - Add to imports: ```python from inbound_verify.domain import SITE_UNDELIVERED_FILE ``` - Replace (line ~448): ```python DOWNLOAD_DIR, expected_undelivered.SITE_UNDELIVERED_FILE.format(name=site) ``` with: ```python DOWNLOAD_DIR, SITE_UNDELIVERED_FILE.format(name=site) ``` - Leave `expected_undelivered.write_site_file(site)` (line ~446) and `expected_undelivered.main()` (line ~476) unchanged (compare behavior). - [ ] **Step 5: Verify — compileall + import smoke + domain is leaf** ```bash 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)** ```bash .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 " ``` --- ## 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_undelivered` no longer exists as a module name. - [ ] **Step 1: git mv the module (history preserved)** ```bash 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: ```python from inbound_verify import expected_undelivered as eu ``` with: ```python from inbound_verify import compare ``` and replace the one call site (line ~213): ```python return eu._read_business_dates(ALL_SITES + ["百世"]) or {} ``` with: ```python 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: ```python from inbound_verify import expected_undelivered ``` with: ```python 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: ```python from inbound_verify import expected_undelivered ``` with: ```python 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** ```bash 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)** ```bash .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 " ``` --- ## 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 `undelivered` for 顺心/中通/韵达/安能 via API → expect 4× success + 4× `*-未到数据.xlsx`; trigger `__compare__` → expect `output/应到未到数据.xlsx`; run `python -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_dates` referenced consistently across tasks. Task 2 routes config to `domain` and 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.