Files
InboundVerify/docs/superpowers/plans/2026-07-23-tier2-domain-rename-dedup.md
Misaka_Company 23042c272b refactor: rename expected_undelivered to compare
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>
2026-07-24 08:40:04 +08:00

19 KiB
Raw Permalink Blame History

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_undeliveredcompare.

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:

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 DOWNLOADS usages with DOWNLOAD_DIR

Apply DOWNLOADSDOWNLOAD_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:

    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.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):

# -*- 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 # 汇总报表... 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:
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:
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) and expected_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.pycompare.py

Files:

  • Move: inbound_verify/expected_undelivered.pyinbound_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)

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 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.