1403 lines
45 KiB
Markdown
1403 lines
45 KiB
Markdown
# 合同班合单写表 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` 改动。
|