AutoRpa/docs/superpowers/specs/2026-10-05-contract-job-int...

267 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 合同班:表 3 合单录入 + 接口 4 组包
| 文档属性 | 内容 |
| --- | --- |
| 日期 | 2026-10-05 |
| 状态 | 已锁定,本轮实施依据 |
| 范围 | `rpa_contract/` 从表 3 领取、按 `supplierId` 合单、调现有页面 runner、尽调闸、写表 4 并回写表 3;`rpa_oms/app/outbound/` 接口 4 组包 |
| 对照 | `docs/RPA订单中心接口开发口径_20260930.md` 第 4 节、第 6 节;`docs/合同系统录入流程.md`;Excel `4合同协议录入结果接口` |
| 不变 | 接口 3 收件、表 3/4 DDL、页面步骤主体(登录/起草/货品/提交/读号)、接口 2 组包形态 |
合同班另起进程。HTTP 收件不同步跑作业。接口 4 真实 path 仍空:只组包,不发 HTTP。
---
## 1. 要做成什么
1. 合同班启动瞬间冻结本班可录名单:表 3 `process_status` 0→3。
2. 按 `supplierId` 合成一份合同;头字段取该组按 `fid` 的第一行;货品行全收。
3. `contractEntryStatus=0` 且已有 `contractNo` 的行不领取,留给返利班。
4. 注入 runner 调现有合同页面流。单测不打开浏览器。
5. 提交成功:表 4 `actionCode=1`、`contractStatus=1`、带合同号;表 3 `process_status=1`。同一合同下多个 `omsId` 回同一套号和状态。
6. 失败(未尽调 / 页面挂):表 4 `contractStatus=3` 带原因;表 3 `process_status=2`。
7. 接口 4 从表 4 取 `push_status=0` 组装 `{"data":[...]}`,一批最多 500。不改 `push_status`,不 POST。
完成标准(内存替身,不连库、不开浏览器):
- `cd rpa_contract` → `uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q`
- 同一供应商两行、两个 `omsId`:runner 调一次;表 4 两行同一合同号;表 3 两条 `process_status=1`
- 带 `contractNo` 的协议重录行不领
- `cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`
---
## 2. 不做什么
- 不实现返利班、不打开返利中心、不回 `contractStatus=2` / `actionCode=2`
- 不向订单中心发 HTTP,不改 `push_status`
- 不在接口 3 请求路径里录合同
- 不按 `omsId` 或供应商名称合单
- 不把下午新进行追加进上午已录合同(冻结快照自然保证)
- 不默认合并/上传 PDF(接口 3 这条链没有平台扫描件)
- 不改表结构、不改接口 1/2/3
- 不改 `rpa_selection/`、`rpa_cart/`、`rpa_rebate/`
- 合同班不 import `rpa_oms`(作业写表 4;组包在 oms 另调,对齐选品写表 2 / 接口 2 另组)
- 不把 JSON 干跑入口 `main.py --input-json` 拆掉
---
## 3. 已锁定决策
| 项 | 决定 |
| --- | --- |
| 包边界 | 领取/合单/落表在 `rpa_contract`;接口 4 组包在 `rpa_oms/app/outbound/contract_agreement.py` |
| 冻结 | `claim_pending` 一次领完本班可录行,0→3;过程中新进的仍是 0,下一班再处理 |
| 可录 | `process_status=0` 且**不是**(`contract_entry_status=0` 且 `contract_no` 非空) |
| 协议重录行 | 永不领取,保持 `process_status=0` |
| 合单键 | `supplier_id`。头字段 = 该组 `fid` 升序第一行 |
| 采购员 / 计划员 | 只存在表 3 行上,本轮不填页面(现有 flow 无对应控件) |
| 表 4 粒度 | 每个 `oms_id` 一行。同一 `oms_id` 多货品仍一行。`org_code` 跟该 `oms_id` 在表 3 的原值 |
| 表 4 `fid` | 序列触发器生成,INSERT 不绑这一列 |
| 表 4 审计 | `create_user_id`/`update_user_id` = 0;名称 = `合同班`;`push_status=0` |
| 客商 | `customerCode` = 第一行 `supplier_mdm_code`;`customerName` = `supplier_name` |
| 日期 | 页面签订日/开始日/结束日 = 第一行 `contract_sign_date` / `contract_start_date` / `contract_end_date`(`YYYY-MM-DD`) |
| S08 兼容 | `ContractFlowContext` 增加可选 `contract_sign_date`、`contract_start_date`。未给时回退 `order_date`(JSON 干跑不破) |
| PDF | `source_type=""`,不走医药城合并上传 |
| 尽调 | S06 之后扫页面正文含「未尽调」则失败,原因固定「客户未尽调」,不提交。无独立选择器 |
| runner | `Callable[[ContractDraft], ContractOutcome]`。生产包装 `run_contract_create_flow`;单测注入 |
| 崩溃 | 停在表 3 `process_status=3`,人工改回 0 再跑;未写表 4 |
| 组包范围 | 全部 `push_status=0`,不按 `actionCode` 过滤(返利以后写 `actionCode=2` 也走同一函数) |
| 组包拆批 | 同一非空 `contractNo` 不拆批,算法对齐接口 2 的 `omsId`。`contractNo` 为空(失败行)按行独立,不合成一组 |
| `omsId` | JSON 整数 |
| HTTP | 不做。path 空 |
---
## 4. 领取与合单
### 4.1 行模型
`PurchaseContractRow`(表 3 已有列,camelCase 不进作业):
`fid, oms_id, supplier_id, supplier_name, contract_sign_date, contract_start_date, contract_end_date, payment_term, goods_id, goods_name, reg_no, spec, unit, qty, unit_price, purchaser_id, purchaser_name, planner_id, planner_name, contract_entry_status, contract_no, process_status, org_code, goods_mdm_code, supplier_mdm_code`
### 4.2 SQL 语义
```
WHERE process_status = 0
AND NOT (NVL(contract_entry_status, -1) = 0
AND contract_no IS NOT NULL
AND LENGTH(TRIM(contract_no)) > 0)
ORDER BY fid
```
领取:对命中行 `UPDATE ... SET process_status = 3, process_time = SYSTIMESTAMP, update_time = SYSTIMESTAMP WHERE fid = :fid AND process_status = 0`。`rowcount=1` 才算领到。可选 `limit` 仅供单测。
`contract_no` 空白串视为空。
### 4.3 分组
对已领取列表按 `fid` 升序扫描,`OrderedDict` 按 `supplier_id` 归组。组内顺序保持 `fid` 升序。每个 `supplier_id` 一份合同。
### 4.4 草稿映射
第一行 → 头:
| 草稿 | 来源 |
| --- | --- |
| `supplier_name` / `customer_name` | `supplier_name` |
| `customer_code` | `supplier_mdm_code` |
| `order_date` | `contract_sign_date`(标题用) |
| `contract_sign_date` / `contract_start_date` / `contract_end_date` | 对应三列,格式 `YYYY-MM-DD` |
| `payment_days` | `str(payment_term)` |
| `is_prepayment` | `否` |
| `payment_method` | `账期支付` |
| `contract_category` | `采购协议` |
| `source_type` | `""` |
| `amount` | `sum(qty * unit_price)`,四位小数字符串 |
| `belong_year` | 签订日年份 |
每一行 → `ContractGoodsLine`:
| 字段 | 来源 |
| --- | --- |
| `operation_code` | `goods_id`(弹窗实际搜 `goods_code`) |
| `goods_code` | `goods_id` |
| `goods_name` | `goods_name` |
| `approval_no` | `reg_no` |
| `spec` / `unit` | 同名 |
| `quantity` | `qty` |
| `mdm_code` | `goods_mdm_code` |
| `unit_price` | 四位小数字符串 |
第一行 `supplier_mdm_code` 为空:整组业务失败,原因「缺少供应商MDM码」,不调 runner。
---
## 5. 页面改动(只两处)
现有 `run_contract_create_flow` 继续串 S02–S13。本轮只加:
1. **S08 日期**(`fill_contract_amount_and_dates`):签订日 = `contract_sign_date or order_date`;开始日 = `contract_start_date or order_date`;结束日仍用 `contract_end_date`。
2. **尽调闸**(S06 之后、S07 之前):`check_customer_diligence` 读页面正文。含「未尽调」则抛 `DiligenceError`(`errors.py`,`NodeError` 子类,文案「客户未尽调」)。生产 runner 让它冒泡到 job。
不改货品弹窗搜索、不改读号策略、不改 JSON 干跑提交前停住的行为。
---
## 6. 班次结果
```python
@dataclass(frozen=True)
class ContractOutcome:
kind: str # "ok" | "diligence" | "fail"
contract_no: str | None
reason: str | None
```
| `kind` | 表 3 `process_status` / `process_result` | 表 4 |
| --- | --- | --- |
| `ok` 且有合同号 | `1` / 成功说明 | `action_code=1`,`contract_status=1`,`contract_no`,失败原因空 |
| `diligence` | `2` / `客户未尽调` | `action_code=1`,`contract_status=3`,`contract_fail_reason=客户未尽调`,`contract_no` 空 |
| `fail` 或其他异常 | `2` / 原因(截到 1000) | `contract_status=3`,`contract_fail_reason` 截到 500,`contract_no` 空 |
| runner 返回 `ok` 但合同号空 | 同 `fail`,「submit 模式未读取到真实合同编号」 | 同 `fail` |
同一组所有表 3 `fid` 写同一套处理状态。表 4 按组内**去重后的 `oms_id`**(首次出现顺序)各插一行,合同号和状态相同;`agreement_*` 全空;`org_code` 取该 `oms_id` 对应表 3 行。
一次 `save_group`:先插表 4,再改表 3。中途崩溃停在 3、表 4 可能部分写入——Oracle 同一连接一事务提交。内存替身同样先写 4 再改 3,一次调用内完成。
生产 runner:由 `ContractDraft` 填 `ContractFlowContext`(含三个日期),调 `run_contract_create_flow`,读到号 → `kind=ok`;捕获 `DiligenceError` → `kind=diligence`;其他 `NodeError` / 异常 → `kind=fail`。
---
## 7. 接口 4 组包
对标 `app/outbound/cart_settlement.py`。
```python
load_contract_agreement_envelope(repo, limit=500) -> {"data": [...]}
```
`repo.list_unpushed()`:`WHERE push_status = 0 ORDER BY fid`。
每条 camelCase:
```
fid, omsId, actionCode, contractStatus, contractFailReason,
contractNo, agreementStatus, agreementFailReason,
agreementId, agreementDtlId, orgCode
```
- `fid` / `omsId` / `actionCode` / 状态码:JSON 整数(库值 `None` 则字段为 `null`)
- 字符串字段原样,空为 `null`
- 不改 `push_status`,不发 HTTP
拆批:按 `contract_no` 分组(仅非空)。空 `contract_no` 每行单独一组。一组放不进当前批且批已有内容则停;批为空则整组放入(可超过 500,对齐接口 2)。
---
## 8. 文件落点
**`rpa_contract/`**(作业扁平,与选品同款):
| 文件 | 职责 |
| --- | --- |
| `group.py` | 可录判定、按 `supplier_id` 分组、草稿映射、金额四位小数 |
| `job.py` | `claim_pending` → 分组 → runner → `save_group` |
| `repo.py` | `MemoryContractRepo` + `OracleContractRepo`:领取、插表 4、回写表 3 |
| `persist.py` | 由 `ContractOutcome` + 组行生成表 4 dict、表 3 状态 |
| `db.py` | 拷选品连接模式;空缺复用 `rpa_oms/.env`;用户名戳 `合同班` |
| `errors.py` | 新增 `DiligenceError` |
| `contract_flow_steps.py` | S08 日期回退;S06 后尽调扫描 |
| `run_batch.py` | 班次入口:`from_env` + 生产 runner。保留现有 `main.py` JSON 干跑 |
| `pyproject.toml` | 加 `oracledb` |
| `tests/test_job.py` | 点名合单/跳过重录/未尽调 |
| `tests/test_group.py` | 头字段取第一行、金额合计、空白 `contract_no` 仍可领 |
**`rpa_oms/`**:
| 文件 | 职责 |
| --- | --- |
| `app/outbound/contract_agreement.py` | `load_` / `build_contract_agreement_envelope` |
| `app/outbound/memory_repo.py` | 增 `MemoryContractAgreementRepo.list_unpushed` |
| `app/outbound/oracle_repo.py` | 增 `OracleContractAgreementRepo`(或同文件新类)只读未推行 |
| `tests/test_contract_agreement_outbound.py` | 点名组包 |
| `examples/contract_agreement_batch.json` | 与 Excel 请求示例同形(`omsId` 用整数) |
| `README.md` | 接口 4:组包已做,path 空,不发 HTTP |
`rpa_contract/README.md`:合单/尽调/写表 4 本轮已接;接口 4 组包在 oms,本班不推。
---
## 9. 验收用例(点名)
### 9.1 `test_job_merges_same_supplier_and_writes_table4`
两行同一 `supplier_id`,`oms_id` 分别为 `2026100501`、`2026100502`,`process_status=0`。第三行 `contract_entry_status=0` 且 `contract_no="HT-OLD"`。runner 记调用次数并返回 `kind=ok, contract_no="HT20261005001"`。
断言:
- runner 调用 1 次;草稿两行货品;客商编码为第一行 MDM
- 表 4 两行:同一 `contract_no`,`action_code=1`,`contract_status=1`,`push_status=0`,`oms_id` 各一
- 前两行表 3 `process_status=1`;第三行仍为 0(未领)
### 9.2 `test_job_marks_diligence_fail`
一组一行。runner 返回 `kind=diligence, reason="客户未尽调"`。
断言:表 3 `process_status=2`,`process_result` 含「客户未尽调」;表 4 `contract_status=3`,`contract_no` 空,`contract_fail_reason="客户未尽调"`。
### 9.3 `test_assembles_unpushed_rows_as_document_envelope`
内存两行 `push_status=0`:一行 `action_code=1, contract_status=1, contract_no="HT1"`;一行 `action_code=2, agreement_status=1`。另有一行 `push_status=1` 不进包。
断言信封 `data` 两条,camelCase 与第 7 节字段一致,`omsId` 为 `int`,已推行不出现。
另测(对齐接口 2 `_take_complete_groups`):同一 `contract_no` 三行、`limit=2`、当前批为空 → 整组三条都放入(允许超过 limit)。两个不同 `contract_no` 各一行、`limit=1` → 只出第一组。
---
## 10. 风险与边界
- 尽调靠正文扫描,页面若换文案会漏拦。本轮不补选择器。
- 货品弹窗仍按 `goods_id` / 品名搜,不改成 MDM。
- 表 4 没有表 3 `fid` 列,回执关联键是表 4 自己的 `fid` 和 `omsId`。
- 真库领取/落表在 Windows 上做,本轮验收不连库。
- 接口 4 path、Token、是否带 `orgSecret` 仍按口径第 6 节后置,本轮不发明 URL。