# 合同班合单写表 4 + 接口 4 组包 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. Use @superpowers:test-driven-development for every code task. **Goal:** 合同班从表 3 冻结可录行、按 `supplierId` 合单、注入 runner 录合同并写表 4;`rpa_oms` 从表 4 组接口 4 报文(不发 HTTP)。 **Architecture:** 作业扁平放在 `rpa_contract/`(`group.py` / `job.py` / `repo.py` / `persist.py`),单测走内存仓库、注入 runner,不打开浏览器、不连库。接口 4 组包对标 `app/outbound/cart_settlement.py`,合同班不 import `rpa_oms`。 **Tech Stack:** Python 3.12、pytest、oracledb(仅 Oracle 仓库)、现有 Selenium 页面流(本轮只加尽调扫描和 S08 日期回退)。 **Spec:** `docs/superpowers/specs/2026-10-05-contract-job-interface4-envelope-design.md` --- ## File structure **Create** | 文件 | 职责 | | --- | --- | | `rpa_oms/app/outbound/contract_agreement.py` | `load_` / `build_contract_agreement_envelope` | | `rpa_oms/tests/test_contract_agreement_outbound.py` | 接口 4 组包点名用例 | | `rpa_oms/examples/contract_agreement_batch.json` | 报文示例(`omsId` 整数) | | `rpa_contract/group.py` | 可录判定、按供应商分组、草稿映射 | | `rpa_contract/persist.py` | `ContractOutcome` → 表 4 dict + 表 3 状态 | | `rpa_contract/job.py` | `claim` → 分组 → runner → `save_group` | | `rpa_contract/repo.py` | `PurchaseContractRow`、`MemoryContractRepo`、`OracleContractRepo` | | `rpa_contract/db.py` | 拷 `rpa_selection/db.py` 连接;戳在 repo 里写「合同班」 | | `rpa_contract/run_batch.py` | 班次入口 | | `rpa_contract/tests/test_group.py` | 合单 / 可录 / 草稿 | | `rpa_contract/tests/test_job.py` | 点名合单写表 4、未尽调 | | `rpa_contract/tests/test_flow_gates.py` | 尽调扫描、S08 日期回退(假 browser,不开页) | **Modify** | 文件 | 改什么 | | --- | --- | | `rpa_oms/app/outbound/memory_repo.py` | 加 `MemoryContractAgreementRepo` | | `rpa_oms/app/outbound/oracle_repo.py` | 加 `OracleContractAgreementRepo` | | `rpa_oms/app/outbound/__init__.py` | 注释改为接口 2、4 都只组包 | | `rpa_oms/README.md` | 接口 4 组包已做,path 空 | | `rpa_contract/errors.py` | `DiligenceError` | | `rpa_contract/contract_flow_steps.py` | S06 后尽调;S08 日期回退;`ContractFlowContext` 两个可选日期 | | `rpa_contract/contract_draft.py` | `contract_sign_date` / `contract_start_date` | | `rpa_contract/contract_create_node.py` | `build_flow_context` 传入两个日期 | | `rpa_contract/pyproject.toml` | `oracledb>=2.4.1` | | `rpa_contract/README.md` | 合单/尽调/写表 4 已接 | | `README.md` | 合同班不再写「尚未接」 | 不要改:`rpa_selection/`、`rpa_cart/`、`rpa_rebate/`、接口 1/2/3、表 DDL、`main.py --input-json`。 --- ### Task 1: 接口 4 组包(点名信封) **Files:** - Create: `rpa_oms/app/outbound/contract_agreement.py` - Modify: `rpa_oms/app/outbound/memory_repo.py` - Test: `rpa_oms/tests/test_contract_agreement_outbound.py` @superpowers:test-driven-development - [ ] **Step 1: Write the failing tests** 在 `memory_repo.py` 末尾先加空类会让 import 过;测试文件先写,类和函数尚不存在。 `rpa_oms/tests/test_contract_agreement_outbound.py`: ```python """接口 4:从表 4 取 push_status=0,组装文档报文。不连 Oracle,不发 HTTP。""" from __future__ import annotations from app.outbound.contract_agreement import ( MAX_BATCH, build_contract_agreement_envelope, load_contract_agreement_envelope, ) from app.outbound.memory_repo import MemoryContractAgreementRepo def _row(**overrides): row = { "fid": 300001, "oms_id": 20260928000001, "action_code": 1, "contract_status": 1, "contract_fail_reason": None, "contract_no": "HT1", "agreement_status": None, "agreement_fail_reason": None, "agreement_id": None, "agreement_dtl_id": None, "org_code": "901", "push_status": 0, } row.update(overrides) return row def test_assembles_unpushed_rows_as_document_envelope(): repo = MemoryContractAgreementRepo( [ _row(), _row( fid=300002, action_code=2, contract_status=None, contract_no="HT1", agreement_status=1, agreement_id="AGR1", agreement_dtl_id="AGRD1", ), _row(fid=300003, push_status=1, contract_no="HT-SKIP"), ] ) envelope = load_contract_agreement_envelope(repo) assert envelope == { "data": [ { "fid": 300001, "omsId": 20260928000001, "actionCode": 1, "contractStatus": 1, "contractFailReason": None, "contractNo": "HT1", "agreementStatus": None, "agreementFailReason": None, "agreementId": None, "agreementDtlId": None, "orgCode": "901", }, { "fid": 300002, "omsId": 20260928000001, "actionCode": 2, "contractStatus": None, "contractFailReason": None, "contractNo": "HT1", "agreementStatus": 1, "agreementFailReason": None, "agreementId": "AGR1", "agreementDtlId": "AGRD1", "orgCode": "901", }, ] } assert isinstance(envelope["data"][0]["omsId"], int) def test_oversized_contract_no_group_enters_when_batch_empty(): rows = [ _row(fid=1, oms_id=1, contract_no="HT-BIG"), _row(fid=2, oms_id=2, contract_no="HT-BIG"), _row(fid=3, oms_id=3, contract_no="HT-BIG"), ] envelope = build_contract_agreement_envelope(rows, limit=2) assert [item["fid"] for item in envelope["data"]] == [1, 2, 3] def test_does_not_split_contract_no_when_batch_already_has_rows(): rows = [ _row(fid=1, oms_id=1, contract_no="HT-A"), _row(fid=2, oms_id=2, contract_no="HT-B"), _row(fid=3, oms_id=3, contract_no="HT-B"), ] envelope = build_contract_agreement_envelope(rows, limit=2) assert [item["fid"] for item in envelope["data"]] == [1] def test_empty_contract_no_rows_are_independent(): rows = [ _row(fid=1, oms_id=1, contract_no=None, contract_status=3), _row(fid=2, oms_id=2, contract_no="", contract_status=3), _row(fid=3, oms_id=3, contract_no="HT-C"), ] envelope = build_contract_agreement_envelope(rows, limit=1) assert [item["fid"] for item in envelope["data"]] == [1] def test_batch_cap_is_500(): assert MAX_BATCH == 500 def test_payload_omits_push_status(): item = build_contract_agreement_envelope([_row()])["data"][0] assert "pushStatus" not in item assert "push_status" not in item ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd rpa_oms && uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q` Expected: FAIL import error(`contract_agreement` 或 `MemoryContractAgreementRepo` 不存在) - [ ] **Step 3: Minimal implementation** `rpa_oms/app/outbound/memory_repo.py` 追加: ```python class MemoryContractAgreementRepo: def __init__(self, rows: list[dict] | None = None): self._rows = [dict(row) for row in (rows or [])] def list_unpushed(self) -> list[dict]: return [dict(row) for row in self._rows if row.get("push_status", 0) == 0] ``` `rpa_oms/app/outbound/contract_agreement.py`(对标 `cart_settlement.py` 的 `_take_complete_groups`,分组键改为非空 `contract_no`;空号用 `("fid", fid)`): ```python """接口 4:从表 4 取未推行,组装文档报文。 同一非空 contractNo 必须在同一批 data 里。一批最多 500 条。 空 contractNo(失败行)按行独立。不改 push_status,不发 HTTP。 """ from __future__ import annotations from collections import OrderedDict MAX_BATCH = 500 def load_contract_agreement_envelope(repo, limit: int = MAX_BATCH) -> dict: return build_contract_agreement_envelope(repo.list_unpushed(), limit=limit) def build_contract_agreement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict: chosen = _take_complete_groups(rows, limit) return {"data": [_to_item(row) for row in chosen]} def _group_key(row: dict): number = row.get("contract_no") if number is None or str(number).strip() == "": return ("fid", row["fid"]) return ("no", str(number).strip()) def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]: groups: OrderedDict = OrderedDict() for row in rows: groups.setdefault(_group_key(row), []).append(row) batch: list[dict] = [] for group in groups.values(): if len(batch) + len(group) > limit and batch: break batch.extend(group) return batch def _to_item(row: dict) -> dict: return { "fid": _as_int(row["fid"]), "omsId": _as_int(row["oms_id"]), "actionCode": _as_int(row["action_code"]), "contractStatus": _as_int(row.get("contract_status")), "contractFailReason": _as_str(row.get("contract_fail_reason")), "contractNo": _as_str(row.get("contract_no")), "agreementStatus": _as_int(row.get("agreement_status")), "agreementFailReason": _as_str(row.get("agreement_fail_reason")), "agreementId": _as_str(row.get("agreement_id")), "agreementDtlId": _as_str(row.get("agreement_dtl_id")), "orgCode": _as_str(row.get("org_code")), } def _as_int(value): if value is None: return None return int(value) def _as_str(value): if value is None: return None text = str(value) if text.strip() == "": return None return text ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd rpa_oms && uv run pytest tests/test_contract_agreement_outbound.py -q` Expected: PASS(6 passed) - [ ] **Step 5: Commit** ```bash git add rpa_oms/app/outbound/contract_agreement.py rpa_oms/app/outbound/memory_repo.py rpa_oms/tests/test_contract_agreement_outbound.py git commit -m "feat: 接口4从表4组装合同协议报文" ``` --- ### Task 2: 接口 4 Oracle 只读 + 示例 + README **Files:** - Modify: `rpa_oms/app/outbound/oracle_repo.py` - Modify: `rpa_oms/app/outbound/__init__.py` - Create: `rpa_oms/examples/contract_agreement_batch.json` - Modify: `rpa_oms/README.md` 本任务无真库单测。SQL 与 `OracleCartSettlementRepo` 同构,只换表和列。 - [ ] **Step 1: Add `OracleContractAgreementRepo`** 在 `oracle_repo.py` 追加(文件头注释改为接口 2/4): ```python _LIST_AGREEMENT_SQL = """ SELECT fid, oms_id, action_code, contract_status, contract_fail_reason, contract_no, agreement_status, agreement_fail_reason, agreement_id, agreement_dtl_id, org_code, push_status FROM rpa_contract_agreement_result WHERE push_status = 0 ORDER BY fid """ class OracleContractAgreementRepo: def __init__(self, connect: Callable): self._connect = connect @classmethod def from_env(cls) -> "OracleContractAgreementRepo": user = os.getenv("RPA_ORACLE_USER", "") password = os.getenv("RPA_ORACLE_PASSWORD", "") host = os.getenv("RPA_ORACLE_HOST", "") if not user or not password or not host: raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD") port = os.getenv("RPA_ORACLE_PORT", "1521") service = os.getenv("RPA_ORACLE_SERVICE", "rpa") dsn = f"{host}:{port}/{service}" def connect(): _ensure_client() import oracledb return oracledb.connect(user=user, password=password, dsn=dsn) return cls(connect=connect) def list_unpushed(self) -> list[dict]: conn = self._connect() try: with conn.cursor() as cur: cur.execute(_LIST_AGREEMENT_SQL) cols = [item[0].lower() for item in cur.description] return [dict(zip(cols, row)) for row in cur.fetchall()] except Exception as exc: raise RepoUnavailable(str(exc)) from exc finally: conn.close() ``` `__init__.py` 改成:「本期接口 2、4 只取数组装,不发 HTTP。」 `examples/contract_agreement_batch.json`(Excel 请求示例,`omsId` 一律整数): ```json { "data": [ { "fid": 300001, "omsId": 20260928000001, "actionCode": 1, "contractStatus": 2, "contractFailReason": null, "contractNo": "HT20260928001", "agreementStatus": null, "agreementFailReason": null, "agreementId": null, "agreementDtlId": null, "orgCode": "901" }, { "fid": 300002, "omsId": 20260928000001, "actionCode": 2, "contractStatus": null, "contractFailReason": null, "contractNo": "HT20260928001", "agreementStatus": 1, "agreementFailReason": null, "agreementId": "AGR20260928001", "agreementDtlId": "AGRD20260928001", "orgCode": "901" } ] } ``` README 接口 4 那一行改为:`app/outbound/contract_agreement.py` 取 `push_status=0` 组装报文。路径仍空,不发 HTTP。在接口 2 示例代码后补接口 4 调用示例: ```python from app.outbound.contract_agreement import load_contract_agreement_envelope from app.outbound.oracle_repo import OracleContractAgreementRepo envelope = load_contract_agreement_envelope(OracleContractAgreementRepo.from_env()) ``` - [ ] **Step 2: Re-run envelope tests** Run: `cd rpa_oms && uv run pytest tests/test_contract_agreement_outbound.py -q` Expected: PASS - [ ] **Step 3: Commit** ```bash git add rpa_oms/app/outbound/oracle_repo.py rpa_oms/app/outbound/__init__.py rpa_oms/examples/contract_agreement_batch.json rpa_oms/README.md git commit -m "feat: 接口4只读表4未推行" ``` --- ### Task 3: 尽调闸 + S08 日期回退 **Files:** - Modify: `rpa_contract/errors.py` - Modify: `rpa_contract/contract_flow_steps.py`(`ContractFlowContext` 约 71 行后;`run_contract_create_flow` 106–108 行之间;`fill_contract_amount_and_dates` 534–557 行) - Modify: `rpa_contract/contract_draft.py`(`ContractDraft` 加两个可选日期字段;`contract_draft_from_dict` 读入) - Modify: `rpa_contract/contract_create_node.py` `build_flow_context`(约 143 行,传入两个日期) - Test: `rpa_contract/tests/test_flow_gates.py` 页面函数仍要 Selenium;日期和尽调抽纯函数 / 假 browser,单测不开页。 - [ ] **Step 1: Write the failing tests** `rpa_contract/tests/test_flow_gates.py`: ```python from contract_flow_steps import ( ContractFlowContext, check_customer_diligence, sign_date_for, begin_date_for, ) from errors import DiligenceError import pytest def _ctx(**overrides) -> ContractFlowContext: data = dict( contract_url="http://example", username="u", password="p", supplier_name="示例", order_date="2026-09-28", source_type="", ) data.update(overrides) return ContractFlowContext(**data) class _FakeBrowser: def __init__(self, text: str): self._text = text def read_body_text(self) -> str: return self._text def test_sign_and_begin_fall_back_to_order_date(): ctx = _ctx() assert sign_date_for(ctx) == "2026-09-28" assert begin_date_for(ctx) == "2026-09-28" def test_sign_and_begin_use_contract_dates_when_set(): ctx = _ctx(contract_sign_date="2026-10-01", contract_start_date="2026-10-02") assert sign_date_for(ctx) == "2026-10-01" assert begin_date_for(ctx) == "2026-10-02" def test_diligence_raises_when_page_says_not_done(): with pytest.raises(DiligenceError, match="客户未尽调"): check_customer_diligence(_FakeBrowser("客户未尽调,不能起草"), _ctx()) def test_diligence_passes_when_text_absent(): check_customer_diligence(_FakeBrowser("客户已尽调"), _ctx()) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd rpa_contract && uv run pytest tests/test_flow_gates.py -q` Expected: FAIL(无 `DiligenceError` / `sign_date_for` / `check_customer_diligence`,或 `ContractFlowContext` 不收 `contract_sign_date`) - [ ] **Step 3: Minimal implementation** `errors.py` 追加: ```python class DiligenceError(NodeError): error_code = "BIZ_CUSTOMER_NOT_DILIGENCE" retryable = False route_role = "PURCHASER" ``` `ContractDraft` 在 `contract_end_date` 旁增加 `contract_sign_date: str = ""`、`contract_start_date: str = ""`;`contract_draft_from_dict` 从 `contractSignDate` / `contractStartDate` 填入。 `ContractFlowContext` 同样在 `contract_end_date` 旁加这两个字段,默认 `""`。 `build_flow_context` 传入: ```python contract_end_date=draft.contract_end_date, contract_sign_date=draft.contract_sign_date, contract_start_date=draft.contract_start_date, ``` `contract_flow_steps.py`: ```python def sign_date_for(ctx: ContractFlowContext) -> str: return (ctx.contract_sign_date or ctx.order_date or "").strip() def begin_date_for(ctx: ContractFlowContext) -> str: return (ctx.contract_start_date or ctx.order_date or "").strip() def check_customer_diligence(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: text = browser.read_body_text() or "" if "未尽调" in text: raise DiligenceError("客户未尽调") ``` `run_contract_create_flow`:`fill_contract_signatory_info` 之后、`fill_contract_basic_info` 之前调用 `check_customer_diligence(browser, ctx)`。 `fill_contract_amount_and_dates`:用 `sign_date_for(ctx)` / `begin_date_for(ctx)` 填签订日、开始日,ACCEPT 期望值同步改成这两个函数的返回值。 - [ ] **Step 4: Run tests to verify they pass** Run: `cd rpa_contract && uv run pytest tests/test_flow_gates.py tests/test_accept.py tests/test_errors.py tests/test_apa_browser_adapter.py -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add rpa_contract/errors.py rpa_contract/contract_flow_steps.py rpa_contract/contract_draft.py rpa_contract/contract_create_node.py rpa_contract/tests/test_flow_gates.py git commit -m "feat: 合同页尽调闸和签订日开始日回退" ``` --- ### Task 4: 可录判定、合单、草稿映射 **Files:** - Create: `rpa_contract/group.py` - Create: `rpa_contract/repo.py`(本任务只放 `PurchaseContractRow` dataclass;Memory/Oracle 下一任务) - Test: `rpa_contract/tests/test_group.py` - [ ] **Step 1: Write the failing tests** `repo.py` 先只定义行模型(测试 import 它)。若希望测试先红,可把 dataclass 放进 Step 3 一起写。 `tests/test_group.py`: ```python from datetime import date from decimal import Decimal from group import ( draft_from_group, group_by_supplier, is_claimable, missing_mdm_reason, money4, ) from repo import PurchaseContractRow def _row(**overrides) -> PurchaseContractRow: data = dict( fid=1, oms_id=2026100501, supplier_id="SUP001", supplier_name="示例供应商", contract_sign_date=date(2026, 10, 1), contract_start_date=date(2026, 10, 2), contract_end_date=date(2027, 9, 30), payment_term=90, goods_id="G0001", goods_name="阿莫西林胶囊", reg_no="国药准字H20201234", spec="0.25g*24粒", unit="盒", qty=Decimal("10.0000"), unit_price=Decimal("100.0000"), purchaser_id="P001", purchaser_name="张三", planner_id="PL001", planner_name="李四", contract_entry_status=None, contract_no=None, process_status=0, org_code="901", goods_mdm_code="QX0100167583", supplier_mdm_code="DW0100046361", ) data.update(overrides) return PurchaseContractRow(**data) def test_protocol_rerun_is_not_claimable(): row = _row(contract_entry_status=0, contract_no="HT-OLD") assert is_claimable(row) is False def test_blank_contract_no_is_still_claimable(): assert is_claimable(_row(contract_entry_status=0, contract_no=" ")) is True assert is_claimable(_row(contract_entry_status=0, contract_no="")) is True assert is_claimable(_row(contract_entry_status=0, contract_no=None)) is True def test_processing_or_done_is_not_claimable(): assert is_claimable(_row(process_status=3)) is False assert is_claimable(_row(process_status=1)) is False def test_group_by_supplier_keeps_fid_order_and_first_header(): rows = [ _row(fid=2, supplier_id="B", payment_term=30, supplier_mdm_code="MDM-B"), _row(fid=1, supplier_id="A", payment_term=90, supplier_mdm_code="MDM-A"), _row(fid=3, supplier_id="A", payment_term=60, oms_id=2026100502, goods_id="G0002"), ] groups = group_by_supplier(rows) assert [g[0].supplier_id for g in groups] == ["A", "B"] assert [r.fid for r in groups[0]] == [1, 3] draft = draft_from_group(groups[0]) assert draft.customer_code == "MDM-A" assert draft.payment_days == "90" assert draft.order_date == "2026-10-01" assert draft.contract_sign_date == "2026-10-01" assert draft.contract_start_date == "2026-10-02" assert draft.contract_end_date == "2027-09-30" assert draft.amount == "2000.0000" assert draft.source_type == "" assert draft.is_prepayment == "否" assert len(draft.goods_lines) == 2 assert draft.goods_lines[0].goods_code == "G0001" assert draft.goods_lines[0].operation_code == "G0001" assert draft.goods_lines[0].unit_price == "100.0000" assert draft.belong_year == "2026" def test_missing_mdm_reason(): rows = [_row(supplier_mdm_code="")] assert missing_mdm_reason(rows) == "缺少供应商MDM码" assert missing_mdm_reason([_row()]) is None def test_money4(): assert money4(Decimal("10.5")) == "10.5000" ``` 金额:两行各 `10 * 100` → `2000.0000`。 - [ ] **Step 2: Run tests to verify they fail** Run: `cd rpa_contract && uv run pytest tests/test_group.py -q` Expected: FAIL import - [ ] **Step 3: Minimal implementation** `repo.py` 行模型(可变,领取时改 `process_status`): ```python from __future__ import annotations from dataclasses import dataclass from datetime import date from decimal import Decimal from typing import Any @dataclass class PurchaseContractRow: fid: int oms_id: int supplier_id: str supplier_name: str contract_sign_date: date | None contract_start_date: date | None contract_end_date: date | None payment_term: int goods_id: str goods_name: str reg_no: str spec: str unit: str qty: Decimal | float | int unit_price: Decimal | float | int purchaser_id: str purchaser_name: str planner_id: str planner_name: str contract_entry_status: int | None contract_no: str | None process_status: int org_code: str | None goods_mdm_code: str | None supplier_mdm_code: str | None process_result: str = "" ``` `group.py`: ```python from __future__ import annotations from collections import OrderedDict from datetime import date, datetime from decimal import Decimal, ROUND_HALF_UP from contract_draft import ContractDraft from contract_flow_steps import ContractGoodsLine from repo import PurchaseContractRow def is_claimable(row: PurchaseContractRow) -> bool: if int(row.process_status or 0) != 0: return False if row.contract_entry_status is not None and int(row.contract_entry_status) == 0: if (row.contract_no or "").strip(): return False return True def group_by_supplier(rows: list[PurchaseContractRow]) -> list[list[PurchaseContractRow]]: groups: OrderedDict[str, list[PurchaseContractRow]] = OrderedDict() for row in sorted(rows, key=lambda item: item.fid): groups.setdefault(row.supplier_id, []).append(row) return list(groups.values()) def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None: if not rows: return "缺少供应商MDM码" if not (rows[0].supplier_mdm_code or "").strip(): return "缺少供应商MDM码" return None def money4(value) -> str: amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) return f"{amount:.4f}" def iso_date(value) -> str: if value is None: return "" if isinstance(value, datetime): return value.date().isoformat() if isinstance(value, date): return value.isoformat() return str(value)[:10] def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft: first = rows[0] sign = iso_date(first.contract_sign_date) total = sum((Decimal(str(row.qty)) * Decimal(str(row.unit_price)) for row in rows), Decimal("0")) goods = [ ContractGoodsLine( operation_code=row.goods_id, goods_code=row.goods_id, goods_name=row.goods_name, approval_no=row.reg_no, spec=row.spec, unit=row.unit, quantity=float(row.qty), mdm_code=row.goods_mdm_code or "", unit_price=money4(row.unit_price), ) for row in rows ] return ContractDraft( supplier_name=first.supplier_name, order_date=sign, source_type="", customer_code=(first.supplier_mdm_code or "").strip(), customer_name=first.supplier_name, contract_category="采购协议", amount=money4(total), contract_sign_date=sign, contract_start_date=iso_date(first.contract_start_date), contract_end_date=iso_date(first.contract_end_date), is_prepayment="否", belong_year=sign[:4], payment_method="账期支付", payment_days=str(first.payment_term), goods_lines=goods, ) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd rpa_contract && uv run pytest tests/test_group.py -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add rpa_contract/group.py rpa_contract/repo.py rpa_contract/tests/test_group.py rpa_contract/contract_draft.py git commit -m "feat: 合同班按供应商合单并映射草稿" ``` (若 Task 3 已提交 `contract_draft.py` 的新字段,本 commit 不必再加该文件。) --- ### Task 5: 内存仓库 + persist **Files:** - Modify: `rpa_contract/repo.py`(`MemoryContractRepo`) - Create: `rpa_contract/persist.py` - Test: `rpa_contract/tests/test_job.py` 先写领取 / persist 小用例;点名合单在 Task 6 - [ ] **Step 1: Write the failing tests** 在 `tests/test_job.py`: ```python from datetime import date from decimal import Decimal from persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome, build_save from repo import MemoryContractRepo, PurchaseContractRow def _row(**overrides) -> PurchaseContractRow: data = dict( fid=1, oms_id=2026100501, supplier_id="SUP001", supplier_name="示例供应商", contract_sign_date=date(2026, 10, 1), contract_start_date=date(2026, 10, 2), contract_end_date=date(2027, 9, 30), payment_term=90, goods_id="G0001", goods_name="阿莫西林胶囊", reg_no="国药准字H20201234", spec="0.25g*24粒", unit="盒", qty=Decimal("10.0000"), unit_price=Decimal("100.0000"), purchaser_id="P001", purchaser_name="张三", planner_id="PL001", planner_name="李四", contract_entry_status=None, contract_no=None, process_status=0, org_code="901", goods_mdm_code="QX0100167583", supplier_mdm_code="DW0100046361", ) data.update(overrides) return PurchaseContractRow(**data) def test_claim_pending_sets_processing_and_skips_protocol_rerun(): repo = MemoryContractRepo( [ _row(), _row(fid=2, oms_id=2026100502, process_status=1), _row(fid=3, oms_id=2026100503, contract_entry_status=0, contract_no="HT-OLD"), ] ) claimed = repo.claim_pending() assert [row.fid for row in claimed] == [1] assert repo.table3[1].process_status == 3 assert repo.table3[3].process_status == 0 def test_save_group_inserts_one_table4_row_per_oms_id(): repo = MemoryContractRepo( [ _row(), _row(fid=2, oms_id=2026100501, goods_id="G0002"), _row(fid=3, oms_id=2026100502, goods_id="G0003"), ] ) repo.claim_pending() outcome = ContractOutcome(KIND_OK, "HT20261005001", None) payload = build_save(list(repo.table3.values())[:3], outcome) repo.save_group(payload) assert [row["oms_id"] for row in repo.table4] == [2026100501, 2026100502] assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"} assert {row["action_code"] for row in repo.table4} == {1} assert {row["contract_status"] for row in repo.table4} == {1} assert {row["push_status"] for row in repo.table4} == {0} assert repo.table3[1].process_status == 1 assert repo.table3[3].process_status == 1 ``` `build_save` 返回一个小对象或 dict,含 `fids`、`process_status`、`process_result`、`table4_rows`。同一 `oms_id` 多货品只留一行,顺序为首次出现。 - [ ] **Step 2: Run tests to verify they fail** Run: `cd rpa_contract && uv run pytest tests/test_job.py::test_claim_pending_sets_processing_and_skips_protocol_rerun tests/test_job.py::test_save_group_inserts_one_table4_row_per_oms_id -q` Expected: FAIL - [ ] **Step 3: Minimal implementation** `persist.py`: ```python from __future__ import annotations from dataclasses import dataclass from repo import PurchaseContractRow KIND_OK = "ok" KIND_DILIGENCE = "diligence" KIND_FAIL = "fail" STAMP = "合同班" PROCESS_RESULT_MAX = 1000 FAIL_REASON_MAX = 500 @dataclass(frozen=True) class ContractOutcome: kind: str contract_no: str | None reason: str | None @dataclass(frozen=True) class GroupSave: fids: list[int] process_status: int process_result: str table4_rows: list[dict] def clip(text: str, max_len: int) -> str: text = (text or "").strip() if len(text) <= max_len: return text return text[: max_len - 1] + "…" def build_save(rows: list[PurchaseContractRow], outcome: ContractOutcome) -> GroupSave: kind = outcome.kind number = (outcome.contract_no or "").strip() or None if kind == KIND_OK and number: process_status = 1 process_result = "合同已提交" contract_status = 1 fail_reason = None contract_no = number elif kind == KIND_DILIGENCE: process_status = 2 process_result = clip("客户未尽调", PROCESS_RESULT_MAX) contract_status = 3 fail_reason = "客户未尽调" contract_no = None else: reason = outcome.reason or "合同录入失败" if kind == KIND_OK and not number: reason = "submit 模式未读取到真实合同编号" process_status = 2 process_result = clip(reason, PROCESS_RESULT_MAX) contract_status = 3 fail_reason = clip(reason, FAIL_REASON_MAX) contract_no = None seen: dict[int, PurchaseContractRow] = {} for row in rows: seen.setdefault(row.oms_id, row) table4 = [ { "oms_id": row.oms_id, "action_code": 1, "contract_status": contract_status, "contract_fail_reason": fail_reason, "contract_no": contract_no, "agreement_status": None, "agreement_fail_reason": None, "agreement_id": None, "agreement_dtl_id": None, "push_status": 0, "org_code": row.org_code, "create_user_id": 0, "create_user_name": STAMP, "update_user_id": 0, "update_user_name": STAMP, } for row in seen.values() ] return GroupSave( fids=[row.fid for row in rows], process_status=process_status, process_result=process_result, table4_rows=table4, ) ``` `MemoryContractRepo`: ```python class MemoryContractRepo: def __init__(self, rows: list[PurchaseContractRow]): self.table3 = {row.fid: row for row in rows} self.table4: list[dict] = [] self._next_fid = 1 def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]: from group import is_claimable pending = sorted( (row for row in self.table3.values() if is_claimable(row)), key=lambda row: row.fid, ) if limit is not None: pending = pending[:limit] for row in pending: row.process_status = 3 return pending def save_group(self, payload: GroupSave) -> None: for row in payload.table4_rows: item = dict(row) item["fid"] = self._next_fid self._next_fid += 1 self.table4.append(item) for fid in payload.fids: target = self.table3[fid] target.process_status = payload.process_status target.process_result = payload.process_result ``` 把 `GroupSave` 的 import 放进 `repo.py` 会循环。`save_group` 的类型用字符串注解,`from persist import GroupSave` 放函数内,或让 `persist` 不 import Memory。`repo.py` 顶层不要 import `persist`;`save_group(self, payload)` 不注类型即可。 - [ ] **Step 4: Run tests to verify they pass** Run: `cd rpa_contract && uv run pytest tests/test_job.py::test_claim_pending_sets_processing_and_skips_protocol_rerun tests/test_job.py::test_save_group_inserts_one_table4_row_per_oms_id tests/test_group.py -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add rpa_contract/persist.py rpa_contract/repo.py rpa_contract/tests/test_job.py git commit -m "feat: 合同班内存领取并写表4" ``` --- ### Task 6: 班次 job 点名用例 **Files:** - Create: `rpa_contract/job.py` - Modify: `rpa_contract/tests/test_job.py` - [ ] **Step 1: Write the failing tests**(追加到 `test_job.py`) ```python from job import run_batch from persist import KIND_DILIGENCE, KIND_OK, ContractOutcome def test_job_merges_same_supplier_and_writes_table4(): repo = MemoryContractRepo( [ _row(), _row(fid=2, oms_id=2026100502, goods_id="G0002"), _row(fid=3, oms_id=2026100503, contract_entry_status=0, contract_no="HT-OLD"), ] ) calls: list = [] def runner(draft): calls.append(draft) return ContractOutcome(KIND_OK, "HT20261005001", None) run_batch(repo, runner=runner) assert len(calls) == 1 assert [line.goods_code for line in calls[0].goods_lines] == ["G0001", "G0002"] assert calls[0].customer_code == "DW0100046361" assert [row["oms_id"] for row in repo.table4] == [2026100501, 2026100502] assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"} assert {row["action_code"] for row in repo.table4} == {1} assert {row["contract_status"] for row in repo.table4} == {1} assert {row["push_status"] for row in repo.table4} == {0} assert repo.table3[1].process_status == 1 assert repo.table3[2].process_status == 1 assert repo.table3[3].process_status == 0 def test_job_marks_diligence_fail(): repo = MemoryContractRepo([_row()]) def runner(draft): return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调") run_batch(repo, runner=runner) assert repo.table3[1].process_status == 2 assert "客户未尽调" in repo.table3[1].process_result assert repo.table4[0]["contract_status"] == 3 assert repo.table4[0]["contract_no"] is None assert repo.table4[0]["contract_fail_reason"] == "客户未尽调" def test_job_skips_runner_when_mdm_missing(): repo = MemoryContractRepo([_row(supplier_mdm_code="")]) called = {"n": 0} def runner(draft): called["n"] += 1 return ContractOutcome(KIND_OK, "HT-X", None) run_batch(repo, runner=runner) assert called["n"] == 0 assert repo.table3[1].process_status == 2 assert "缺少供应商MDM码" in repo.table3[1].process_result ``` 注意:`_row()` 默认 `supplier_mdm_code="DW0100046361"`,与 spec 9.1「客商编码为第一行 MDM」一致。 - [ ] **Step 2: Run tests to verify they fail** Run: `cd rpa_contract && uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q` Expected: FAIL(`job` / `run_batch` 不存在) - [ ] **Step 3: Minimal implementation** `job.py`: ```python """合同班次:领取表 3 → 按供应商合单 → runner 录合同 → 写表 4。""" from __future__ import annotations from dataclasses import dataclass from typing import Callable from contract_draft import ContractDraft from group import draft_from_group, group_by_supplier, missing_mdm_reason from persist import KIND_FAIL, KIND_OK, ContractOutcome, build_save Runner = Callable[[ContractDraft], ContractOutcome] @dataclass class JobSummary: claimed: int = 0 groups: int = 0 ok: int = 0 failed: int = 0 def run_batch(repo, *, runner: Runner, limit: int | None = None) -> JobSummary: claimed = repo.claim_pending(limit=limit) summary = JobSummary(claimed=len(claimed), groups=0) for rows in group_by_supplier(claimed): summary.groups += 1 outcome = _run_group(rows, runner) repo.save_group(build_save(rows, outcome)) if outcome.kind == KIND_OK and (outcome.contract_no or "").strip(): summary.ok += 1 else: summary.failed += 1 return summary def _run_group(rows, runner: Runner) -> ContractOutcome: reason = missing_mdm_reason(rows) if reason: return ContractOutcome(KIND_FAIL, None, reason) try: outcome = runner(draft_from_group(rows)) except Exception as exc: return ContractOutcome(KIND_FAIL, None, str(exc)) if outcome.kind == KIND_OK and not (outcome.contract_no or "").strip(): return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号") return outcome ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd rpa_contract && uv run pytest tests/test_job.py tests/test_group.py tests/test_flow_gates.py -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add rpa_contract/job.py rpa_contract/tests/test_job.py git commit -m "feat: 合同班合单录入写表4" ``` --- ### Task 7: Oracle 仓库 + 生产 runner + 班次入口 **Files:** - Create: `rpa_contract/db.py`(拷 `rpa_selection/db.py`,删掉 `DEAL_USER_NAME`,保留 `load_runtime_env` / `connect_from_env` / `RepoUnavailable`) - Modify: `rpa_contract/repo.py`(`OracleContractRepo`) - Create: `rpa_contract/run_batch.py` - Modify: `rpa_contract/pyproject.toml`(`oracledb>=2.4.1`) - Test: `rpa_contract/tests/test_job.py` 追加 `test_outcome_from_run_*`(不连库、不开页) 生产 runner 捕获 `DiligenceError` → `KIND_DILIGENCE`,其它异常 → `KIND_FAIL`。抽 `outcome_from_run` 方便单测。 - [ ] **Step 1: Write the failing tests** ```python from errors import DiligenceError, NodeError from job import outcome_from_run from persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome def test_outcome_from_run_maps_diligence(): def run(): raise DiligenceError("客户未尽调") out = outcome_from_run(run) assert out.kind == KIND_DILIGENCE assert out.reason == "客户未尽调" assert out.contract_no is None def test_outcome_from_run_maps_empty_number_to_fail(): out = outcome_from_run(lambda: "") assert out.kind == KIND_FAIL assert "未读取到真实合同编号" in (out.reason or "") def test_outcome_from_run_ok(): out = outcome_from_run(lambda: "HT9") assert out == ContractOutcome(KIND_OK, "HT9", None) def test_outcome_from_run_maps_node_error(): def run(): raise NodeError("客商加载失败") out = outcome_from_run(run) assert out.kind == KIND_FAIL assert "客商加载失败" in (out.reason or "") ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd rpa_contract && uv run pytest tests/test_job.py::test_outcome_from_run_ok -q` Expected: FAIL(无 `outcome_from_run`) - [ ] **Step 3: Implement runner helper, Oracle repo, CLI** `job.py` 追加: ```python from errors import DiligenceError, NodeError def outcome_from_run(run: Callable[[], str]) -> ContractOutcome: try: number = (run() or "").strip() except DiligenceError: return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调") except NodeError as exc: return ContractOutcome(KIND_FAIL, None, str(exc)) except Exception as exc: return ContractOutcome(KIND_FAIL, None, str(exc)) if not number: return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号") return ContractOutcome(KIND_OK, number, None) ``` `production_runner` 放 `run_batch.py`,避免 `job.py` 在单测里拉起 Selenium: ```python from __future__ import annotations from apa_browser_adapter import ApaBrowserAdapter from contract_create_node import CONTRACT_URL, build_flow_context from contract_create_node import ContractCreateInput from contract_draft import ContractDraft from contract_flow_steps import run_contract_create_flow from db import load_runtime_env from job import outcome_from_run, run_batch from persist import ContractOutcome from repo import OracleContractRepo def production_runner(draft: ContractDraft) -> ContractOutcome: spec = ContractCreateInput( job_id="contract-batch", supplier_name=draft.supplier_name, order_date=draft.order_date, source_type=draft.source_type or "", submit_mode="submit", ) ctx = build_flow_context(spec, draft) browser = ApaBrowserAdapter(url=ctx.contract_url, flow="CONTRACT_CREATE") def run() -> str: return run_contract_create_flow(browser, ctx) return outcome_from_run(run) def main() -> int: load_runtime_env() repo = OracleContractRepo.from_env() summary = run_batch(repo, runner=production_runner) print(f"claimed={summary.claimed} groups={summary.groups} ok={summary.ok} failed={summary.failed}") return 0 if summary.failed == 0 else 1 if __name__ == "__main__": raise SystemExit(main()) ``` `OracleContractRepo`:`from_env` 用 `db.connect_from_env`。领取 SQL 按 spec 4.2;`rowcount==1` 才进 claimed。`save_group` 同一连接先 INSERT 表 4(不绑 `fid`),再 UPDATE 表 3 `process_status=3` 的那些 `fid`。审计字段 `0` / `合同班`。`create_time`/`update_time`/`process_time` 用 `SYSTIMESTAMP`。 SELECT 列与 `PurchaseContractRow` 字段一一对应。日期列用 cursor 原样(`date`/`datetime`)。 `pyproject.toml` dependencies 加 `"oracledb>=2.4.1"`,然后 `cd rpa_contract && uv lock`。 `db.py`:整文件拷 `rpa_selection/db.py`,删除 `DEAL_USER_NAME`。 - [ ] **Step 4: Run tests to verify they pass** Run: `cd rpa_contract && uv run pytest tests/test_job.py tests/test_group.py tests/test_flow_gates.py -q` Expected: PASS 不要跑带页面等待的套件当回归。不要连真库。 - [ ] **Step 5: Commit** ```bash git add rpa_contract/db.py rpa_contract/repo.py rpa_contract/job.py rpa_contract/run_batch.py rpa_contract/pyproject.toml rpa_contract/uv.lock rpa_contract/tests/test_job.py git commit -m "feat: 合同班 Oracle 领取与班次入口" ``` --- ### Task 8: 文档收口 **Files:** - Modify: `rpa_contract/README.md` - Modify: `README.md`(合同班那一行) - Modify: `rpa_oms/README.md`(若 Task 2 已写接口 4,本任务只补合同班怎么用组包) - Modify: `Agents.md` 合同班落点表:合单/写表 4 已接;接口 4 组包在 oms,path 空。不要改选品/加购节。 - [ ] **Step 1: Update READMEs** `rpa_contract/README.md`: - 读表 3 `process_status=0`(跳过协议重录行);写表 4 `actionCode=1` - 合单 / 尽调 / 写表 4 本轮已接 - 接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status` - 班次:`uv run python run_batch.py`;JSON 干跑入口仍是 `uv run python main.py --input-json ...` - 验收:`uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q` 根 `README.md`:`rpa_contract/` 那一行改为「合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空」。`rpa_oms/` 那一行补「接口 2、4 组装报文」。 不要改 `rpa_rebate/` 文案。不要把密码写进 README。 - [ ] **Step 2: Re-run named accept tests** ```bash cd rpa_oms && uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q cd rpa_contract && uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 tests/test_job.py::test_job_marks_diligence_fail -q ``` Expected: PASS - [ ] **Step 3: Commit** ```bash git add rpa_contract/README.md README.md rpa_oms/README.md git commit -m "docs: 合同班合单与接口4组包已接" ``` --- ## 完成标准 1. `cd rpa_contract && uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q` 绿。 2. `cd rpa_oms && uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q` 绿。 3. JSON 干跑 `main.py --input-json` 仍在。 4. 无 HTTP 客户端,无 `push_status` 更新,无返利代码,无 `rpa_selection` / `rpa_cart` 改动。