diff --git a/.gitignore b/.gitignore
index ee56bd1..eaaf15c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ node_modules/
# Local runtime / agent
.runtime/
rpa_contract/screenshots/
+rpa_rebate/screenshots/
rpa-chrome-profile/
**/chrome-profile/
.tmp/
diff --git a/AGENTS.md b/AGENTS.md
index 412d4b4..8a02d29 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# AutoRpa
-洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)、**选品班次**(`rpa_selection/`)、**加购班次**(`rpa_cart/`)和**合同班次**(`rpa_contract/`):收件落 Oracle,请求里不跑选品、不录合同;选品另起进程捞 `deal_flag=0`;加购另起进程领表 2 成功行;合同另起进程领表 3。返利 `rpa_rebate/` 仍是占位。根上不再有 `选品流程/`、`state_center/`、`rpa_yisaiqi/`。
+洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)、**选品班次**(`rpa_selection/`)、**加购班次**(`rpa_cart/`)、**合同班次**(`rpa_contract/`)和**返利班次**(`rpa_rebate/`):收件落 Oracle,请求里不跑选品、不录合同;选品另起进程捞 `deal_flag=0`;加购另起进程领表 2 成功行;合同另起进程领表 3;返利另起进程领表 4 起草行 / 表 3 只录协议行。根上不再有 `选品流程/`、`state_center/`、`rpa_yisaiqi/`。
智力用在**定落点、想清楚对错**;速度和 token 靠下面这张地图定点打开,禁止用扫仓代替思考。改对应了,反工和消耗都会少。
@@ -35,11 +35,12 @@
| 文字验收 / 截图策略 | `rpa_contract/accept.py` | 只读 `ACCEPT.txt`,禁止读 png |
| 加购班次 | `rpa_cart/cart_job.py`、`cart_repo.py`、`cart_match.py` | `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` |
| 支付回传 | `rpa_cart/job.py`、`settle.py` | 1 号药城支付按 Excel 落表,不对加购行 |
-| 返利班 | `rpa_rebate/README.md` | 占位,未实现 |
+| 返利班查号落表 | `rpa_rebate/job.py`、`persist.py`、`repo.py` | `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` |
+| 返利班查号页面 | `rpa_rebate/rebate_flow_steps.py`、`rebate_finder.py` | `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/tests/test_finder.py::test_find_flow_writes_accept_for_missing_number` |
| 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — |
| 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` |
-禁止打开:`node_modules/`、`chrome-profile/`、任何 png。Chrome 用户目录只在仓库外 `D:\work\rpa-chrome-profile`。Oracle 密码放本机 `rpa_oms/.env`(选品空缺时复用);商城账号放 `rpa_selection/.env`。都已 gitignore,不要写进仓库、README、示例 JSON,不要 `git add` / commit / push。
+禁止打开:`node_modules/`、`chrome-profile/`、任何 png。Chrome 用户目录只在仓库外 `D:\work\rpa-chrome-profile`。Oracle 密码放本机 `rpa_oms/.env`(选品空缺时复用);商城账号放 `rpa_selection/.env`;返利中心账号放 `rpa_rebate/.env`。都已 gitignore,不要写进仓库、README、示例 JSON,不要 `git add` / commit / push。
## 接口1 落点(订单中心)
@@ -123,13 +124,29 @@
成功路径默认不截图(`RPA_SCREENSHOT=always` 才全截)。页面等待用 `wait_for_text` / `wait_for_element`,不要固定长睡。
+## 返利班落点
+
+班次作业在 `rpa_rebate/`,不在接口 3 请求里跑。领表 4 `action_code=1 AND contract_status=1`(合同班起草、尚未回成功),以及表 3 `contract_entry_status=0` 且已有 `contractNo` 的只录协议行。按合同号查返利中心;搜到再回 `contractStatus=2`、录协议写 `actionCode=2`。搜不到不重录合同,留给下一班。查号走页面;录协议仍用替身。接口 4 组包在 oms,path 空,不发 HTTP。
+
+| 步 | 函数 | 要点 |
+| --- | --- | --- |
+| 领取 | `claim_pending` | 表 4 盖 `查号中`;表 3 `process_status` 0→3。崩溃停戳,改回 `合同班` / `0` 再跑 |
+| 查号 | `RebateFinder` / `search_contract` | 打开 `REBATE_URL` → 登录 → 采购协议 → 按合同号搜;表里出现该号才 `True`,输入框回显不算 |
+| 落表 | `build_found` + `save_group` | 起草行 `contract_status=2`;每 `oms_id` 插 `action_code=2`、`agreement_status=1`;`push_status=0` |
+| 搜不到 | `build_not_found` | 戳改回 `合同班`;表 3 改回 `0`;不插协议行 |
+
+验收:`cd rpa_rebate` → `uv run pytest tests/test_job.py::test_job_finds_contract_and_writes_agreement -q`。页面:`uv run python run_find.py`,只读 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。接口 4:`cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`。
+
+录协议、映射表默认值、送审下一刀。不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。
+
## 返利中心及之后
-`rpa_rebate/` 仍是占位。合同审批通过后才进这一段。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开做时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。
+查号页面已接 `REBATE_FIND`。录协议班次骨架仍是替身。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。
-| 节点 | 谁做 | 要回传 | 页面要做什么(尚未拆函数) |
+| 节点 | 谁做 | 要回传 | 页面要做什么 |
| --- | --- | --- | --- |
-| `REBATE_HEADER` | RPA | `rebateOrderNo` | 登录返利中心 → 按合同编号查总单 → 填必填 → 保存拿总单号 |
+| `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 |
+| `REBATE_HEADER` | RPA | `rebateOrderNo` | 尚未拆:填必填 → 保存拿总单号 |
| `REBATE_IMPORT` | RPA | 成功/失败行 | 下模板 → 按字段填 Excel → 上传 → 读导入结果 |
| `REBATE_SUBMIT` | RPA | `submitted` | 点提交送审 |
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
@@ -146,6 +163,8 @@
- 选品:点名 `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。
- 加购:点名 `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` 或 `test_job_marks_out_of_stock`。
- 合同班:点名 `rpa_contract/tests/test_job.py::test_job_merges_same_supplier_and_writes_table4` 或 `test_job_marks_diligence_fail`。
+- 返利班:点名 `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` 或 `test_job_leaves_not_found_for_next_shift`。
+- 返利查号页:只读控制台 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。
- 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。
- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。
diff --git a/README.md b/README.md
index 136b0d4..cced2a7 100644
--- a/README.md
+++ b/README.md
@@ -23,14 +23,14 @@
| `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
| `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 |
| `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 |
-| `rpa_rebate/` | 返利班(占位)。查号、录协议 → 表 4 |
+| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号走页面,录协议仍用替身 |
| 包 | 读 | 写 | 口 |
| --- | --- | --- | --- |
| `rpa_selection` | 表 1 `deal_flag=0` | 表 2 `operate_type=1`,表 1 处理标志 | 不直接调口;口 2 由 `rpa_cart` 推 |
| `rpa_cart` | 表 2 | 表 2 `push_status`;支付行 | 口 2 |
| `rpa_contract` | 表 3 `process_status=0` | 表 4 `actionCode=1` | 口 3 收件在 oms;口 4 回写 |
-| `rpa_rebate` | 表 3 / 表 4 待查号与待录协议 | 表 4 `contractStatus=2`、`actionCode=2` | 口 4 |
+| `rpa_rebate` | 表 4 起草行 / 表 3 只录协议行 | 表 4 `contractStatus=2`、`actionCode=2` | 口 4(组包在 oms,path 空) |
合同怎么录、合单和回写口径见 `docs/合同系统录入流程.md`。页面控件以 `rpa_contract/contract_flow_steps.py` 为准。
diff --git a/docs/superpowers/specs/2026-09-12-jieda-four-flow-design-codex.md b/docs/superpowers/specs/2026-09-12-jieda-four-flow-design-codex.md
new file mode 100644
index 0000000..5f8ef56
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-12-jieda-four-flow-design-codex.md
@@ -0,0 +1,212 @@
+> **历史版本,已合并替代。** 当前请使用[福建洁达采购 RPA 概要设计与详细设计 V3.0](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-13-jieda-rpa-integrated-design.md)。合同与返利一次接收、共用接口头明细,以新稿为准。下文保留历史内容,不作为当前实施依据。
+
+# 福建洁达 RPA 四流程设计稿
+
+版本:V1.9 · 2026-09-12
+配套:[技术实现说明 V2.3](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-12-jieda-four-flow-impl.md)。本次为文档修订,应用代码及数据库尚未改动。
+
+## 1. 范围和运行方式
+
+本期做四条流程:选品加购、平台订单导出推送、合同录入、返利录入。业务可以点击各自脚本执行,也可以设置不同时间、不同频率自动执行。一条脚本处理完本批数据就退出。
+
+FastAPI 接收订单中心推送的数据,校验必填字段,缺项当次返回失败及具体字段;通过后保存到 RPA 表并返回成功。RPA 按各自排期读取未处理数据,不在接收请求中同步执行四条流程。
+
+业务范围以本次确认内容为准;字段依据[合同返利字段映射](/Users/yuuuu/Documents/AutoRpa/资料/合同返利系统字段映射_20260911.xlsx),选品依据[采购条件](/Users/yuuuu/Documents/AutoRpa/资料/采购平台选品条件优先项_20260902.txt),合同和返利操作参考 `资料/合同系统录入流程.md`。手工单暂不做。
+
+## 2. 四条流程
+
+### 2.1 选品加购 `PROCURE`
+
+```text
+读取订单中心已传入的未处理销售明细
+→ 取一条批准文号去平台查询
+→ 满足采购条件:加购物车,记录已加购
+→ 不满足:记录已跳过及原因
+→ 下一条 → 本批处理完结束
+```
+
+本次业务已确认,相同批准文号只有一个规格。选品按批准文号查询,不增加规格、厂家、包装和单位换算匹配。
+
+每条记录已加购、已跳过或执行失败。再次执行读取未处理及需要重试的数据。人工付款在平台由业务处理,第一条脚本不等付款。
+
+### 2.2 平台订单导出推送 `ORDER_PUSH`
+
+```text
+从对应平台导出订单 → 保存到该平台对应的共享文件夹
+→ 按订单中心接口格式组装数据 → 推送订单中心
+→ 记录接收结果 → 本批结束
+```
+
+1 药城、药帮忙分别配置共享目录及导出字段映射。保留原订单文件、来源订单号和推送结果。对码在订单中心内部完成,本方不增加对码表、候选确认或对码轮询。
+
+### 2.3 合同录入 `CONTRACT`
+
+```text
+读取已接收成功、未处理的合同数据
+→ 打开合同系统,选择客户,填写合同头和导入明细
+→ 汇总金额并保存合同 → 记录合同号和保存结果
+→ 调用订单中心链路接口更新对应节点 → 完成
+```
+
+必填字段在接收时检查,成功落表后直接供 RPA 录入。合同系统保存成功后才更新订单中心节点;更新失败保留“合同已保存、链路更新失败”,再次处理只补接口调用。
+
+按原业务流程读取尽调回显;未尽调由采购员处理,本条停止自动录入,继续下一条。沿用业务要求的通知方式,不新增审批步骤。
+
+### 2.4 返利录入 `REBATE`
+
+```text
+读取已接收的返利数据 → 按合同号查询采购协议
+→ 填写返利总单 → 导入细单 → 提交
+→ 记录成功或失败 → 下一条
+```
+
+字段按第 4 节填写。提交失败按原流程交业务处理。
+
+## 3. 选品条件
+
+前四项全部满足后,按仓库名单顺序选择。
+
+| 条件 | 业务要求 |
+| --- | --- |
+| 自营仓 | 业务勾选的自营仓 |
+| 盈利 | 对比销售价格,有 5% 以上盈利 |
+| 有效期 | 大于等于 12 个月 |
+| 库存 | 满足需求采购数量 |
+| 仓库顺序 | 按以下名单优先选择 |
+
+广东壹号药业有限公司、昆山亿方药业有限公司、湖北亿昊药业有限公司、天津壹号药业有限公司、重庆亿昊药业有限公司、重庆壹号医药有限公司、湖南壹号药业股份有限公司、辽宁亿昊药业有限公司、河南壹号医药有限公司、甘肃壹号云医药有限责任公司企业店、陕西壹号药业有限公司。
+
+## 4. 合同及返利字段
+
+以下字段和行号对应 Excel 的两个业务工作表。订单中心提供的字段在接收时校验;固定默认值由程序填写,系统生成及页面回显字段不要求上游必传。
+
+### 4.1 合同头
+
+| 目标字段 | 取值规则 | 行号 |
+| --- | --- | --- |
+| 客户名称 | 订单中心 | 12 |
+| 客户编码 | 目标系统自动生成 | 11 |
+| 尽调情况 | 读取回显;未尽调停止本单自动录入并转采购员处理 | 13 |
+| 结算类型 | 收支双向 | 15 |
+| 合同版本类型 | 预付:我司定制;账期:非格式合同 | 17 |
+| 合同金额(单位元) | 预估本次合作金额 | 24 |
+| 金额(元) | 明细汇总;与导入后页面汇总核对并回填 | 25 |
+| 合同签订日期、合同开始日期、合同结束日期 | 订单中心分别提供,三个独立字段 | 26–28 |
+| 是否重大合同 | 否 | 29 |
+| 是否预付款 | 预付:是;账期:否 | 30 |
+| 付款方式 | 电汇 | 32 |
+| 付款期限 | 预付:1;账期:订单中心账期天数,缺失不默认 30 | 33 |
+| 合同具体分类 | 国内药品类、器械类品种 | 37 |
+| 运输方式 | 公路运输 | 39 |
+
+### 4.2 合同明细
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 货品编码 | 订单中心 | 43 |
+| 货品名称 | 订单中心标准货品名称 | 44 |
+| 注册证号 | 订单中心 | 46 |
+| 规格、单位 | 按订单中心标准字段填报 | 47–48 |
+| 数量、单价 | 按订单中心最终采购数量、单价填报 | 49–50 |
+| 付款方式 | 电汇 | 52 |
+| 付款条件 | 订单中心;模板无此字段时不填 | 53 |
+| 付款期限 | 同合同头规则 | 54 |
+
+### 4.3 返利总单
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 返利合约 | 否 | 5 |
+| 供应商编码 | 订单中心 | 6 |
+| 返利单位编码 | 订单中心 | 7 |
+| 计划员 | 订单中心,按业务给定的计划员/采购员对应关系填写 | 8 |
+| 采购结算方式 | 文案“否”;与细单代码 3 分别映射 | 9 |
+| 承付模式 | 下月 N 号结 | 10 |
+| 协议开始日期、协议结束日期 | 当天 | 11–12 |
+
+### 4.4 返利细单
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 货品编码、数量、含税协议单价 | 订单中心返利源数据 | 15–17 |
+| 税率、关税税率 | 均为 0.13 | 18–19 |
+| 承付模式、承付参数 | 11、25 | 20–21 |
+| 细单开始日期 | 当天 | 22 |
+| 细单结束日期 | 本年度最后一天 | 23 |
+| 采购结算方式 | 3 | 24 |
+| 特定补偿预估值、进货补偿预估值 | 均为 0 | 25–26 |
+| 细单备注 | 订单中心订单号 | 33 |
+| 货源细单ID | 订单中心货源细单号;与订单号分列 | 34 |
+
+合同明细导入列按 Excel 的 Sheet3。表中标注无需填写的字段不填;系统自动生成字段由系统处理。
+
+## 5. 接口和存储
+
+### 5.1 接口方向
+
+| 方向 | 数据 | 处理 |
+| --- | --- | --- |
+| 订单中心 → 本方 | 销售总单及明细 | 校验后落表,供选品使用 |
+| 订单中心 → 本方 | 合同头及明细 | 校验后落表,供合同录入使用 |
+| 订单中心 → 本方 | 返利头及明细、计划员对应信息 | 校验后落表,供返利使用 |
+| 本方 → 订单中心 | 平台导出的订单 | 按正式接口格式推送,保存接收结果 |
+| 本方 → 订单中心 | 合同保存结果及对应链路节点 | 保存成功后调用更新接口,保存返回结果 |
+
+接收成功表示数据校验通过并已保存。缺必填返回字段错误,订单中心补齐后重传;数据库保存失败返回失败。相同来源单据重复传入时,按双方约定的来源标识识别,避免重复执行。
+
+### 5.2 需要保存的数据
+
+表名为建议,开发时先核对已有 RPA Oracle 表,能复用的直接复用。正式列名和类型按接口报文及 Excel 确定。
+
+| 逻辑表/现有记录 | 保存内容 |
+| --- | --- |
+| `RPA_JD_SALES_HEAD / LINE` | 来源销售单号、明细标识、批准文号、采购数量、销售价格及上游提供的商品信息 |
+| `RPA_JD_CONTRACT_HEAD / LINE` | 第 4 节合同字段、来源标识、订单中心链路关联 |
+| `RPA_JD_REBATE_HEAD / LINE` | 第 4 节返利字段、合同/协议关联及计划员对应信息 |
+| 现有 `job / job_node` | 流程类型、来源数据关联、处理状态、已加购/已跳过结果、合同号、失败原因及执行次数 |
+| 现有 `job_artifact / job_event` | 平台订单文件路径、平台、来源订单号、发送报文和接收结果;合同链路请求和返回结果 |
+
+一个业务头关联它自己的明细,订单号和明细标识分别保存。原平台订单文件保存在共享目录。选品和订单推送不另建一套主数据匹配表。
+
+业务数据放 RPA Oracle,执行状态复用 MySQL 状态中心。脚本通过状态中心 API 取数和回报,由后端统一读写数据库。
+
+## 6. 对当前代码的改动
+
+| 位置 | 本次需要改什么 |
+| --- | --- |
+| `state_center/app/main.py` | 增加销售/合同/返利接收及取数接口;必填在接收时校验;按流程返回待处理数据 |
+| `state_center/app/db.py` 及数据库脚本 | 接入 RPA 业务表;job 增加流程类型和来源关联,原供应商+日期键改为适合各流程的来源标识 |
+| `state_center/app/web_state_machine.py` | 按流程定义节点及完成条件 |
+| `rpa_yisaiqi/agent.py`、`runtime/shell.py`、`callback_client.py` | 支持四个独立脚本入口,取数、逐条处理、回报及本批退出 |
+| 选品与平台导出模块 | 接入平台查询/加购、订单导出、共享目录保存、报文组装及推送 |
+| `contract_draft.py`、`contract_create_node.py`、`contract_flow_steps.py` | 按第 4 节字段录入,保存成功后调用链路更新接口;原页面操作可复用部分继续使用 |
+| 返利执行模块 | 按第 4 节字段导入和提交;真实页面流程需补齐 |
+| 前端列表/详情 | 按四条流程展示状态、文件、结果及失败原因,复用现有页面 |
+
+当前实现仍是一条固定主链;平台真实查询/导出能力需核对现有模块,不能把 mock 当作已完成。整体改动集中在数据接入、流程拆分和接口适配,合同页面操作有可复用基础。
+
+具体执行节点和开发顺序见[技术实现说明](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-12-jieda-four-flow-impl.md)。
+
+## 7. 验收
+
+1. 接收数据缺必填当次失败;补齐后可重传,成功数据能被对应脚本读取。
+2. 选品合格加购、不合格跳过,处理完本批结束,不等待付款。
+3. 平台订单保存到对应共享文件夹,按接口格式推送并记录结果。
+4. 合同字段按 Excel 填写;保存成功后更新订单中心节点,更新失败不重复建合同。
+5. 返利按字段映射填写并记录提交结果。
+6. 四条脚本按各自时间和频率执行,失败可定位到具体记录,已完成数据不重复处理。
+
+## 8. 待补充信息和可能出现的问题
+
+以下是联调需要补齐的信息及实现注意点,不增加业务操作步骤。
+
+| 项目 | 需要处理的内容 |
+| --- | --- |
+| 正式接口和文件 | 提供订单中心接收/链路更新格式、节点代码,平台导出样例、共享路径和四条排期 |
+| 5% 盈利 | 原材料未给出计算公式,需明确按什么基数计算 |
+| 合同/返利模板 | 确认实际导入模板、报文中每份合同及返利明细的范围;附件及审批要求以实际系统和业务确认为准,不先设计额外流程 |
+| 重复运行 | 同一脚本运行中再次点击不重复启动;重复传单或重新导出同一订单不重复办理 |
+| 接口超时 | 记录本次请求及错误,结果不明先核对;合同已保存时仅补链路更新 |
+| 平台/共享目录故障 | 查询失败和无合格商品分开记录;文件保存失败时记录错误,不继续推送 |
+| 当前合同代码 | 缺单价默认 1.00、同编码行定位和前 30 行扫描等行为需修正;接收缺价应直接返回错误 |
+| 旧流程影响 | 新四流程按 flow 区分,保留旧在途单的原节点和处理方式,避免改全局节点影响其他流程 |
diff --git a/docs/superpowers/specs/2026-09-12-jieda-four-flow-impl.md b/docs/superpowers/specs/2026-09-12-jieda-four-flow-impl.md
new file mode 100644
index 0000000..e0af79c
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-12-jieda-four-flow-impl.md
@@ -0,0 +1,92 @@
+> **历史版本,已合并替代。** 当前请使用[福建洁达采购 RPA 概要设计与详细设计 V3.0](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-13-jieda-rpa-integrated-design.md)。合同与返利一次接收、共用接口头明细,以新稿为准。下文保留历史内容,不作为当前实施依据。
+
+# 福建洁达四流程技术实现说明
+
+版本:V2.3 · 2026-09-12
+配套:[设计稿 V1.9](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-12-jieda-four-flow-design-codex.md)。本文为待实施说明,代码和数据库尚未修改。
+
+## 1. 在现有结构上改
+
+FastAPI 接收并校验订单中心数据,RPA Oracle 保存业务头明细,Oracle 继续管理 job、节点、日志和结果。四条脚本分别由业务点击或定时任务启动,不在一个 HTTP 请求里同步执行四条流程。
+
+现有 `job` 增加流程类型和来源数据关联;按各流程配置节点。优先复用 `job_node / job_event / job_artifact`,不另建运行中心、对码中心或多套状态表。
+
+## 2. 接收和取数
+
+建议本方接收入口为 sales、contract-data、rebate-data,正式路径开发时统一命名。每个入口执行:
+
+```text
+检查对应必填字段 → 不通过:返回失败和字段位置
+→ 通过:同事务保存业务头明细 → 返回成功
+```
+
+字段来源和默认值按[设计稿第 4 节](/Users/yuuuu/Documents/AutoRpa/docs/superpowers/specs/2026-09-12-jieda-four-flow-design-codex.md)。系统生成和回显字段不作为上游必填。失败由订单中心修正重传,不建立等待补字段的业务作业。
+
+取数接口按 flow 返回成功接收且未处理的数据。保存来源单号及明细标识,用于识别重复传入。状态中心登记或复用对应 job;领取时将其标记处理中,避免重复执行。脚本逐条回报,处理完本批退出。
+
+## 3. 四条脚本
+
+| flow | job 对象 | 执行节点 | 结束结果 |
+| --- | --- | --- | --- |
+| PROCURE | 一条销售明细 | `PROCURE_ITEM_RUN` | 已加购或已跳过;执行失败记原因 |
+| ORDER_PUSH | 一次平台订单导出批次 | `ORDER_EXPORT_PUSH` | 文件已保存,应推送订单已接收;失败保留具体订单和原因 |
+| CONTRACT | 一份上游合同数据及其明细 | `CONTRACT_CREATE → CONTRACT_CHAIN_UPDATE` | 合同已保存,订单中心节点已更新 |
+| REBATE | 一份返利数据及其明细 | `REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT` | 提交成功或失败 |
+
+**选品脚本:** 按批准文号查询,依次判断自营仓、盈利、效期、库存和仓库顺序;合格加购物车,不合格记录跳过,继续下一条。同批准文号按一个规格处理,不增加属性匹配。复用节点结果记录已加购/已跳过,不等待人工付款。
+
+**订单脚本:** 登录平台导出订单,保存到平台对应共享目录,按映射生成订单中心报文并推送。原文件、来源订单号、发送内容和回执保存在现有文件/日志记录中。再次处理依据已接收订单记录跳过成功项。
+
+**合同脚本:** 读取成功接收的合同头明细,复用页面操作填写并保存。确认保存成功后回报合同号,进入链路更新节点。该节点通过订单中心接口适配器调用更新接口并回报结果。两步在同一条合同流程中执行,失败后恢复当前步骤;不增加独立后台调度任务。
+
+**返利脚本:** 按合同号查询协议,填写总单、导入明细并提交,回报实际结果。原业务要求的未尽调合同及返利失败交业务处理,继续其他记录。
+
+## 4. 数据如何保存
+
+| 内容 | 保存位置 |
+| --- | --- |
+| 销售头明细 | RPA Oracle 销售表,字段按订单中心数据及选品条件 |
+| 合同头明细及链路关联 | RPA Oracle 合同表,字段按 Excel |
+| 返利头明细及计划员对应信息 | RPA Oracle 返利表,字段按 Excel |
+| 流程、处理状态、合同号、失败原因 | 现有 Oracle job/job_node |
+| 文件路径及推送/链路调用记录 | 现有 job_artifact/job_event |
+| 平台原始订单文件 | 对应平台共享文件夹 |
+
+表名及列定义见设计稿第 5 节;开发前对照现场已有表和正式报文确定。头明细按来源关联保存,不用供应商+日期代替所有业务唯一标识,不自行合并合同。
+
+处理状态复用现有待处理、处理中、成功、失败等状态。选品正常跳过属于处理完成,业务结果记录为“已跳过”。合同的“保存成功”和“链路更新成功”分别记录,更新失败不回退到重新建合同。
+
+## 5. 代码改动
+
+| 文件/模块 | 改动 |
+| --- | --- |
+| `state_center/app/main.py` | 接收校验、取数及结果接口;按 flow 创建和查询作业 |
+| `state_center/app/db.py`、数据库脚本 | RPA 业务表访问、头明细事务、job 的 flow 和来源字段;成功数据重复传入不重复建单 |
+| `state_center/app/web_state_machine.py` | 四条节点配置;旧作业继续用旧配置 |
+| `state_center/app/config.py` | 数据库、订单中心接口、平台共享路径和字段映射配置 |
+| 订单中心接口适配器 | 两项调用:平台订单推送、合同保存后的链路节点更新;路径及参数按对方协议 |
+| `rpa_yisaiqi/agent.py`、`runtime/shell.py`、`callback_client.py` | 四种脚本入口、取数循环、实际执行次数、结果回报、失败步骤恢复 |
+| 选品/平台导出模块 | 登录查询、采购筛选、加购、导出、共享文件保存及报文组装 |
+| `contract_draft.py`、`contract_create_node.py`、`contract_flow_steps.py` | 按 Excel 字段录入;确认保存结果,衔接链路接口;修正错误单价默认值及明细定位 |
+| 返利模块 | 补真实页面填写、导入、提交操作 |
+| 前端列表/详情 | 展示四种流程及已有状态,增加文件、接收结果和链路更新结果 |
+
+现有固定主链、供应商+日期建单规则不能直接套用到全部新流程。商城实际登录/查询/导出模块需核对是否已有可用代码;现有 mock 代理和返利 runner 不能算真实执行已完成。
+
+## 6. 开发顺序
+
+1. 按正式报文和 Excel 确定字段,核对已有表,完成接收校验和落表。
+2. 增加 flow、取数、结果记录及四个脚本入口,复用现有状态中心。
+3. 完成平台导出、共享归档和订单推送。
+4. 完成合同字段适配,接上保存后的链路更新接口。
+5. 完成逐条选品加购及返利页面操作。
+6. 在实际执行机配置四条排期,按设计稿第 7 节联调验收。
+
+## 7. 待确认及可能的问题
+
+- 订单中心正式字段、链路节点代码、平台导出样例、共享路径及排期需提供;5% 盈利公式需明确。
+- 重复点击、重复传单、重复导出可能重复办理;使用现有状态和来源标识防重,同一流程不重入。
+- 保存或推送成功后回报失败,不能直接重做;先查看已有文件、回执或目标合同结果,再从失败步骤继续。
+- 合同链路更新失败只补接口调用;不额外增加后台维护任务。
+- 当前合同缺价填 1.00、同编码行定位及前 30 行扫描行为需要处理;具体风险见设计稿末尾。
+- 新四流程改动按 flow 区分,保留旧在途单节点,避免影响旧 OCR、对码和 M5 流程。
diff --git a/docs/superpowers/specs/2026-09-12-jieda-four-stage-api-design.md b/docs/superpowers/specs/2026-09-12-jieda-four-stage-api-design.md
new file mode 100644
index 0000000..553a51e
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-12-jieda-four-stage-api-design.md
@@ -0,0 +1,222 @@
+# 福建洁达:接口数据入 RPA 库(小改)
+
+> 日期:2026-09-12
+> 范围更正:流程仍是 RPA,状态机不动。改的是图上那些「从系统/表来」的数据——FastAPI 调接口,落到对方 RPA Oracle,带状态,再给 RPA 用。
+> 上一版把四步理解成换主链、砍 M5,范围过大,作废。
+
+---
+
+## 1. 改什么、不改什么
+
+不改:
+
+- 艺赛旗照旧点 1 药城 / 药帮忙 / 合同系统 / 返利中心
+- 状态中心 MySQL 状态机、`start/complete/fail`、待办、幂等
+- 人工闸门:选品复核付款、未尽调、返利失败
+
+改:
+
+- 原先靠导 Excel / 页面上抄的**主数据、对照表、总单**,改为接口拉取
+- FastAPI 按四步拉数,写入 `RPADBTEST`(`192.168.12.244/rpa`,用户 `rpainit`)
+- 每张业务表带处理状态,失败可重拉、可跳过、可给 RPA 重跑,不把半截 JSON 丢给机器人
+
+```
+对方接口 → FastAPI(四步同步) → Oracle 暂存表(带 STATUS)
+ ↓
+ RPA 读 READY 行,点页面
+ ↓
+ 回写 SUCCESS/FAILED
+ ↓
+ 状态中心节点 complete/fail
+```
+
+两套状态分工:
+
+| 库 | 记什么 | 不记什么 |
+| --- | --- | --- |
+| Oracle `RPADBTEST` | 这批数据到了没有、RPA 用过没有 | 整单流程走到哪一步 |
+| MySQL `rpa_center` | job / 节点 / 待办 | 接口原始报文(必要时只存镜像) |
+
+不要在 Oracle 再做一套 13 节点状态机。
+
+---
+
+## 2. 四步对应图上哪些数据
+
+图上没有写「接口」二字。按「这格是取数/对照表,不是点按钮」切开。点按钮的仍归 RPA。
+
+| 步 | FastAPI | 图上取数 | RPA 随后用它做什么 |
+| --- | --- | --- | --- |
+| 1 | `POST /api/jieda/sales/sync` | 订单中心「销售总单」 | 按行里的批准文号去 1 药城 / 药帮忙搜品、筛选、加购 |
+| 2 | `POST /api/jieda/match/sync` | 手工单 DocAgent 结果;提取字段;对码商家信息;回写订单中心对码表的入参 | 合同里填「客户名称」;对码表有数才能往下 |
+| 3 | `POST /api/jieda/contract-refs/sync` | 客户是否已尽调;《客户类型》预付/账期;合同明细导入用的货品行 | 选格式/非格式、填模板、导明细、回填合同金额 |
+| 4 | `POST /api/jieda/rebate-refs/sync` | 采购总单;计划员↔采购员对照;返利细单模板行 | 按合同号查总单、填必填项、导细单 |
+
+第 1 步失败:RPA 不要上平台乱搜。
+第 3 步尽调=否:RPA 不填合同,走现有人工待办(邮件通知采购员)。
+第 4 步对照表缺失:RPA 不猜计划员,节点 fail。
+
+---
+
+## 3. Oracle 表(建议新建,前缀 `RPA_JD_`)
+
+连接(只放环境变量,不进仓库):
+
+```
+RPA_ORACLE_HOST=192.168.12.244
+RPA_ORACLE_PORT=1521
+RPA_ORACLE_SERVICE=rpa
+RPA_ORACLE_USER=rpainit
+RPA_ORACLE_PASSWORD=...
+```
+
+本机目前 TCP `192.168.12.244:1521` 不通,建表要在能进这台库的机器上执行。先 `SELECT table_name FROM user_tables`,有现成表就映射,不要重复造。
+
+### 3.1 每张业务表共用状态列
+
+```
+JOB_ID VARCHAR2(64) -- 对齐状态中心 job_id
+BATCH_ID VARCHAR2(64) -- 一次 sync
+STATUS VARCHAR2(16) -- NEW / READY / RUNNING / SUCCESS / FAILED / SKIPPED
+ERR_CODE VARCHAR2(64)
+ERR_MSG VARCHAR2(500)
+ATTEMPT_NO NUMBER(4)
+RAW_JSON CLOB -- 接口原文,排障用
+SYNCED_AT DATE
+UPDATED_AT DATE
+IDEMPOTENCY_KEY VARCHAR2(128)
+```
+
+行状态只表示**这条数据**:
+
+- FastAPI 写入成功 → `READY`
+- 接口失败、校验失败 → `FAILED`(RPA 不抢)
+- RPA 开始用 → `RUNNING`
+- 页面做完 → `SUCCESS`
+- 业务跳过(如未尽调)→ `SKIPPED`
+
+同一 `JOB_ID + IDEMPOTENCY_KEY` 再 sync:已 `SUCCESS` 的不覆盖;`FAILED` 允许重拉。
+
+### 3.2 四步表
+
+**第 1 步 销售总单**
+
+- `RPA_JD_SALES_HEAD`:销售总单号、订单日期、客户/商家、金额、来源
+- `RPA_JD_SALES_LINE`:批准文号、品名、规格、厂家、数量、单价、平台商品编码
+
+**第 2 步 对码 / 手工单**
+
+- `RPA_JD_MATCH_PARTY`:对码商家信息(合同「客户名称 / 客户编码」从这里取)
+- `RPA_JD_MATCH_LINE`:对码后货品 ID/编码、是否命中;也承接 DocAgent 抽出的手工单行
+- 回写订单中心对码表:另调对方写入接口,本地留 `PUSH_STATUS`
+
+**第 3 步 合同辅助**
+
+- `RPA_JD_CUSTOMER_REF`:客户编码、是否已尽调、客户类型(预付/账期)、应用哪套合同模板
+- `RPA_JD_CONTRACT_LINE`:按合同明细导入模板整理好的货品行(RPA 只负责上传,不在页面上拼 Excel)
+
+**第 4 步 返利辅助**
+
+- `RPA_JD_PLANNER_MAP`:计划员 ↔ 采购员(可全量同步,不必每单)
+- `RPA_JD_REBATE_HEAD`:采购总单上要填进返利总单的字段
+- `RPA_JD_REBATE_LINE`:细单导入行
+
+列级 DDL 等对方接口字段确定后再锁。现在只锁表职责和状态机,避免先猜 40 个列名再推翻。
+
+---
+
+## 4. FastAPI(状态中心里加一层,不新起服务)
+
+四个同步入口,鉴权仍用 `X-Agent-Token`。
+
+```
+POST /api/jieda/sales/sync
+POST /api/jieda/match/sync
+POST /api/jieda/contract-refs/sync
+POST /api/jieda/rebate-refs/sync
+```
+
+入参最小集:`job_id`、`order_date`、可选 `idempotencyKey`。
+出参:`ok`、`batch_id`、`ready_count`、`failed_count`、`status`。
+
+每个 sync:
+
+1. 调对方接口(没有正式文档前走 fixture,契约用 Pydantic 写死)
+2. 校验必填(第 1 步无批准文号直接 `FAILED`)
+3. upsert 到对应 Oracle 表,`STATUS=READY`
+4. 写状态中心事件;该节点仍由 RPA `start/complete` 推进,**sync 成功不等于节点成功**
+
+查询给 RPA:
+
+```
+GET /api/jieda/{job_id}/sales
+GET /api/jieda/{job_id}/contract-refs
+...
+```
+
+RPA 也可以直连 Oracle 读 `READY` 行。推荐先走 FastAPI 读,避免脚本里写死账号;直连作为艺赛旗侧备选。
+
+RPA 回写状态:
+
+```
+POST /api/jieda/{job_id}/rows/{table}/{id}/status
+body: { "status": "RUNNING|SUCCESS|FAILED", "errMsg": "..." }
+```
+
+内部更新 Oracle 行状态。节点级成功仍走原来的 `/nodes/{node}/complete`。
+
+---
+
+## 5. 和现有节点怎么接(不大改)
+
+不改 `NODE_ORDER`。四步 sync 挂在现有节点**开始前**:
+
+| 现有节点 | 开始前多做的 sync | RPA 行为变化 |
+| --- | --- | --- |
+| `ORDER_FETCH` | `sales/sync` | 不再以导 Excel 为唯一数据源;以 Oracle 总单/明细为准去搜品 |
+| `OCR_EXTRACT` / 对码前 | `match/sync` | 线下单走 DocAgent 结果表;商家名称从表读 |
+| `CONTRACT_CREATE` | `contract-refs/sync` | 尽调、客户类型、明细行从表读;未尽调不点填报 |
+| `REBATE_HEADER` / `REBATE_IMPORT` | `rebate-refs/sync` | 总单必填项、计划员、细单行从表读 |
+
+`PRODUCT_MATCH`、`CONTRACT_APPROVE_WAIT`、`M5_*` 先不动。新图没画 M5,不等于本期删 M5。
+
+艺赛旗每个泳道开头加一个「取数」代码块:调对应 `sync`(或确认 scheduler 已 sync)→ 读 `READY` → 再点页面。失败把行打成 `FAILED` 并调节点 `fail`。
+
+---
+
+## 6. 代码改动面(小)
+
+| 位置 | 做什么 |
+| --- | --- |
+| `state_center/app/config.py` | 增加 `RPA_ORACLE_*`,密码只走环境变量 |
+| `state_center/app/rpa_oracle.py` 新建 | 连接、upsert、按 job 查询、改行状态 |
+| `state_center/app/jieda_sync.py` 新建 | 四个 sync 的编排 + Pydantic |
+| `state_center/app/adapters/` 新建 | 订单中心 / 尽调 / 客户类型 / 计划员 的 HTTP client,先 fixture |
+| `state_center/app/main.py` | 挂 4 个 sync + 查询 + 行状态 |
+| `state_center/sql/rpa_jd_oracle.sql` 新建 | 上面那些表的 DDL |
+| `rpa_yisaiqi` | 合同/选品/返利流程开头读 FastAPI 或 Oracle,不改状态机顺序 |
+| 测试 | sync 幂等、失败不覆盖 SUCCESS、无批准文号 → FAILED、未尽调 → SKIPPED |
+
+密码和 TNS 不进 git。`connection.local.json` 已有先例。
+
+---
+
+## 7. 落地顺序
+
+1. 能连 `192.168.12.244` 的机器上看 `rpainit` 已有表,避免重名。
+2. 建第 1 步两张表 + `sales/sync` fixture,RPA 能把批准文号读出来。
+3. 第 3 步尽调 / 客户类型(合同填报刚需)。
+4. 第 4 步计划员对照 + 采购总单。
+5. 第 2 步对码回写和 DocAgent,等对方对码接口。
+
+没有接口文档时,每步用一份 JSON fixture 冒充对方响应,表结构和 FastAPI 先定。
+
+---
+
+## 8. 还缺的输入
+
+- 四个接口的 URL、鉴权、字段(尤其销售总单、尽调、客户类型、计划员对照)
+- `rpainit` 是否已有同类暂存表
+- RPA 读数:走我们 FastAPI,还是艺赛旗直连 Oracle
+
+这三项不齐也能先建表和 fixture;接真接口时只换 adapter。
diff --git a/docs/superpowers/specs/2026-09-13-jieda-rpa-detailed-design.md b/docs/superpowers/specs/2026-09-13-jieda-rpa-detailed-design.md
new file mode 100644
index 0000000..5d27dda
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-13-jieda-rpa-detailed-design.md
@@ -0,0 +1,683 @@
+# 福建洁达采购 RPA 详细设计
+
+| 文档属性 | 内容 |
+| --- | --- |
+| 版本 | V3.1 |
+| 日期 | 2026-09-13 |
+| 配套概要设计 | [《福建洁达采购 RPA 概要设计》](./2026-09-13-jieda-rpa-overview-design.md) |
+| 状态 | 设计评审稿;未实施代码或建表 |
+| 适用范围 | 接口接收、四条流程执行、状态记录、运维与异常恢复 |
+
+本文件承接概要设计,规定可供开发、测试和运维执行的表、字段、状态、接口和处理规则。外部正式字段名、节点代码和现场 Oracle 表结构以第 9 章未决事项为准。
+
+**本期范围收敛:**只实现订单接收、平台订单推送、选品、合同、返利四条流程及基本状态记录。版本冲突、内容哈希、执行令牌、文件证据表、复杂事件分类和自动化核验接口不作为本期新增必做项;现场已有能力可以复用,确需新增时另行确认。
+
+## 4. 详细设计:数据模型
+
+### 4.1 实体关系和存储位置
+
+```mermaid
+flowchart LR
+ IF[接口接收记录
RPA_JD_IF_REQ]
+ SH[销售接口头
RPA_JD_SALES_H]
+ SL[销售接口明细
RPA_JD_SALES_L]
+ OH[合同返利共用头
RPA_JD_ORDER_H]
+ OL[合同返利共用明细
RPA_JD_ORDER_L]
+ J[流程作业
JOB]
+ N[作业节点
JOB_NODE]
+ E[操作事件
JOB_EVENT]
+ A[文件证据
JOB_ARTIFACT]
+ P[订单推送台账
JD_ORDER_PUSH]
+ IF --> SH
+ IF --> OH
+ SH --> SL
+ OH --> OL
+ SL --> J
+ OH --> J
+ J --> N
+ J --> E
+ J --> A
+ J --> P
+```
+
+*图中括号内为数据库表名,前面的中文为业务含义。*
+
+本节所有实体均存放在同一套 **Oracle 数据库**,不再区分接口库、状态库或 MySQL。实体按用途分为三组:
+
+| 分组 | 表 | 作用 |
+| --- | --- | --- |
+| 接口输入表 | `RPA_JD_IF_REQ`、`RPA_JD_SALES_H/L`、`RPA_JD_ORDER_H/L` | 保存订单中心传入的原始报文、销售数据及合同/返利共用数据 |
+| 流程业务表 | `JOB`、`JOB_NODE`、`JOB_EVENT`、`JOB_ARTIFACT` | 保存四条流程的作业、节点执行、操作记录和文件证据 |
+| 订单推送表 | `JD_ORDER_PUSH` | 保存平台订单导出后推送订单中心的请求、回执和推送状态 |
+
+接口输入数据只保存一份。合同和返利通过相同的 `INPUT_ID`、`INPUT_VERSION` 关联;两条流程分别在 `JOB` 中记录自己的状态,不复制两套合同/返利输入表。已有表能承载上述用途时优先复用,只有确实缺少的表或字段才在 Oracle 中新增。图中的关联由主键、外键或业务关联字段实现,具体以现场 Oracle 表结构核对结果为准。
+
+接口表内的合同/返利状态不各写一份。运维总览按 INPUT_ID 汇总两份 job 与其节点,展示三个独立标志:合同保存、链路更新、返利办理。读写来源见第 5 章。
+
+### 4.2 接收记录 `RPA_JD_IF_REQ`
+
+一行是一回接收尝试;错误请求也能查到,不能依赖先有 job 才记录。
+
+| 字段 | 建议类型 | 含义与约束 |
+| --- | --- | --- |
+| REQUEST_LOG_ID | VARCHAR2(64) PK | 本方生成,返回给上游和运维定位 |
+| TRACE_ID | VARCHAR2(64) | 本次请求关联应用日志 |
+| SOURCE_SYSTEM / MESSAGE_ID | VARCHAR2(32) / VARCHAR2(128) | 调用方及其请求标识;MESSAGE_ID 格式由对方提供 |
+| DATA_TYPE | VARCHAR2(24) | SALES 或 ORDER_COMMON;没有独立 REBATE 接收类型 |
+| BODY_HASH | VARCHAR2(64) | 规范化业务报文哈希,识别同请求内容是否一致 |
+| RECEIVE_STATUS | VARCHAR2(16) | ACCEPTED、REJECTED、DUPLICATE、CONFLICT |
+| ORIGINAL_REQ_ID | VARCHAR2(64),可空 | 重复请求关联首次成功记录 |
+| RAW_BODY / RESPONSE_BODY | CLOB | 实际业务报文和返回内容,不记录鉴权口令 |
+| ERROR_CODE / ERROR_DETAIL | VARCHAR2(64) / CLOB | 字段路径、明细标识、错误说明 |
+| RECEIVED_AT / RESPONDED_AT | TIMESTAMP | 收到及生成返回结果的时间 |
+| REGISTER_STATUS | VARCHAR2(8),可空 | 成功记录的作业登记标志:PENDING、DONE、FAILED;非成功记录为空 |
+| REGISTER_ERROR / REGISTERED_AT | VARCHAR2(500) / TIMESTAMP | 作业登记失败原因及最终登记时间 |
+
+RECEIVE_STATUS 表示接收事实,REGISTER_STATUS 仅表示是否已登记作业,均不代表合同或返利完成。成功接收同事务保存接口头明细并置 REGISTER_STATUS=PENDING;脚本取数前补登记,完成后更新 DONE。登记失败在下一次脚本取数重试,无需上游重传。
+
+约束:对 ACCEPTED 记录建立 `(SOURCE_SYSTEM, MESSAGE_ID)` 条件唯一索引;Oracle 可用 CASE 表达式索引实现。失败/重复尝试保留独立行,不占用成功消息键。索引还包括 `(DATA_TYPE, REGISTER_STATUS, RECEIVED_AT)`、`TRACE_ID`、`(SOURCE_SYSTEM, MESSAGE_ID)`。
+
+### 4.3 接口头公共字段
+
+销售头和共用订单头均使用以下控制列。
+
+| 字段 | 建议类型 | 用途 |
+| --- | --- | --- |
+| INPUT_ID | VARCHAR2(64) PK | 本方输入身份,跨流程统一关联 |
+| REQUEST_LOG_ID | VARCHAR2(64) FK | 首次成功接收记录 |
+| SOURCE_SYSTEM / SOURCE_DOC_ID | VARCHAR2(32) / VARCHAR2(128) | 上游稳定业务标识;与展示单号分开 |
+| SOURCE_DOC_NO | VARCHAR2(128) | 业务查询展示用单号 |
+| INPUT_VERSION | VARCHAR2(64) | 上游版本;未提供时首版用固定 1,变更走第 6.2 节冲突规则 |
+| DATA_HASH | VARCHAR2(64) | 本对象完整头明细内容哈希 |
+| DATA_STATUS | VARCHAR2(16) | READY:成功接收的完整输入;本期不另设待补字段状态 |
+| CREATED_AT | TIMESTAMP | 成功保存时间 |
+
+头表唯一键为 `(SOURCE_SYSTEM, SOURCE_DOC_ID, INPUT_VERSION)`。明细以 `(INPUT_ID, SOURCE_LINE_ID)` 唯一,关联本头,不以货品编码唯一。成功输入不由 RPA 改写,处理结果记录在 Oracle 业务表中。
+
+### 4.4 销售接口头明细 `RPA_JD_SALES_H / L`
+
+| 表 | 业务字段 | 用途 |
+| --- | --- | --- |
+| SALES_H | 第 4.3 节公共字段、BUSINESS_DATE | 一份销售总单 |
+| SALES_L | LINE_ID、INPUT_ID、SOURCE_LINE_ID、APPROVAL_NO、PURCHASE_QTY、SALES_PRICE | 逐条查询所需批准文号、需求采购数量、销售价格 |
+| SALES_L 可选描述 | GOODS_NAME、SPEC、MANUFACTURER、UNIT | 按上游原值保留供查看,不作为新增属性匹配门槛 |
+
+LINE_ID 建议 VARCHAR2(64),数量 NUMBER(18,4),价格 NUMBER(18,6),标识/文号 VARCHAR2(128),描述 VARCHAR2(256)。最终长度和精度按真实接口及目标系统核对。
+
+### 4.5 合同/返利共用接口头明细 `RPA_JD_ORDER_H / L`
+
+一份头代表上游明确给定的一组合同及对应返利数据,范围由报文的 SOURCE_DOC_ID 和明细集合确定。首版不按供应商+日期自行合单。若实际存在一组数据对应多份合同/返利,需要上游给出组标识,见第 12 章。
+
+| 位置 | 建议字段 | 含义/使用流程 |
+| --- | --- | --- |
+| ORDER_H | 第 4.3 节公共字段 | 合同和返利共用输入标识 |
+| ORDER_H | CUSTOMER_NAME、CUSTOMER_TYPE、CREDIT_DAYS | 合同客户名称、预付/账期类型、账期天数 |
+| ORDER_H | CONTRACT_SIGN_DATE、CONTRACT_START_DATE、CONTRACT_END_DATE | 三个独立合同日期 |
+| ORDER_H | SUPPLIER_CODE、REBATE_UNIT_CODE、PLANNER、PURCHASER | 返利供应商、返利单位、计划员及采购员对应信息 |
+| ORDER_H | CHAIN_REF | 订单中心链路/源业务关联;精确结构按正式接口 |
+| ORDER_L | LINE_ID、INPUT_ID、SOURCE_LINE_ID、ORDER_NO、SOURCE_DETAIL_ID | 本方行 ID、来源行、订单号、货源细单号;订单号与货源细单号分列 |
+| ORDER_L | GOODS_CODE、GOODS_NAME、REG_CERT_NO、SPEC、UNIT | 标准货品编码/名称、注册证号、规格、单位 |
+| ORDER_L | PURCHASE_QTY、PURCHASE_UNIT_PRICE | 采购数量和单价;用于合同明细 |
+| ORDER_L | REBATE_QTY、REBATE_TAX_PRICE | 返利数量和含税协议单价;用于返利明细 |
+| ORDER_L | PAYMENT_CONDITION,可空 | 实际合同模板需要时从同一报文提供 |
+
+同一次报文中数量如只有一个来源字段且供两流程共用,映射层同时引用它;不因此要求上游重复传两个数量。采购单价与含税协议单价按 Excel 的不同含义分别映射,未经接口确认不互相代替。
+
+建议数量 NUMBER(18,4)、价格 NUMBER(18,6)、金额 NUMBER(18,2)、日期 DATE;名称 VARCHAR2(256)、编码 VARCHAR2(128)、类型 VARCHAR2(16)、账期 NUMBER(6)、CHAIN_REF CLOB 或按正式关联键拆列。固定默认值不强迫订单中心传入,在节点执行输入中生成并记录所用模板/映射版本。
+
+**此处没有 `RPA_JD_CONTRACT_HEAD / RPA_JD_REBATE_HEAD` 两套输入表。两流程取数的 INPUT_ID 必须相同。**
+
+### 4.6 业务作业与节点记录
+
+| 表/字段 | 现有或新增 | 详细用途 |
+| --- | --- | --- |
+| job.flow_code / flow_version | 新增 VARCHAR(24) / VARCHAR(16) | 旧作业归原主链,新作业区分四条流程 |
+| job.input_id / input_version | 新增 VARCHAR(64) | 指向 Oracle 成功输入;ORDER_PUSH 指向本方导出批次 ID |
+| job.source_line_id | 新增 VARCHAR(128),头级作业用空串 | PROCURE 对应一条销售明细;合同/返利对应头 |
+| job.business_key | 复用 | 改为上述来源关联的展示/定位键;防重以组合唯一约束为准 |
+| job.status / current_node_code | 复用 | 当前执行状态和恢复节点 |
+| job.contract_system_no / rebate_order_no | 复用 | 合同 job 保存合同号,返利 job 保存返利总单/协议编号 |
+| job.next_check_at | 复用 | 可重试失败或外部等待的下次处理时间 |
+| job.execution_token / execution_host | 本期不新增 | 如现有状态中心已有执行占用机制则继续复用,否则按单实例运行约束处理 |
+| job_node.attempt_no、status、output_json | 复用 | 每次尝试独立记录;输入引用、业务结果和外部编号 |
+| job_node.started_at / ended_at / duration_ms | 复用 | 操作开始结束及耗时 |
+| job_node.error_code / error_summary | 复用 | 可筛选错误分类和具体原因 |
+| job_node.executor_run_id / callback_idempotency_key | 复用 | 一次脚本运行 ID、稳定回报键,重发回报不重复推进 |
+
+本期按 `(flow_code, input_id, source_line_id)` 识别同一流程作业;已有系统若必须保留版本字段,可作为可选扩展,不因规则升级自动创建第二份作业。
+
+共用数据成功接收后登记两份 job:CONTRACT 初始 PENDING,REBATE 初始 WAITING_EXTERNAL,原因为 WAIT_CONTRACT。同一 Oracle 输入即便登记调用重复,也只建立这一对作业。一次接收多组业务对象则逐头建立,不共享一个总完成标志。
+
+旧列兼容迁移:保留 job_id、business_key、source_type、current_node_code、status、created_by 非空;supplier_name 和 order_date 改为允许空值,新来源数据有值时填入。旧入口继续校验其原必填,不给新作业补造供应商或日期。business_key 由新唯一键各项组合生成可读/哈希值;created_by 记录业务启动人或计划任务账号。
+
+| flow | source_type | supplier_name / order_date | 首节点 |
+| --- | --- | --- | --- |
+| PROCURE | 本方定义 ORDER_CENTER | 供应商为空;日期取销售业务日(若提供) | PROCURE_ITEM_RUN |
+| ORDER_PUSH | 明确的平台配置代码 | 文件批次可含多供应商,供应商为空;日期不强制单日 | ORDER_EXPORT_FILE |
+| CONTRACT / REBATE | ORDER_CENTER | 接口明确提供的展示供应商及业务日期;合同三个日期仍取接口独立字段 | CONTRACT_CREATE / REBATE_HEADER |
+
+source_type 上述取值是本方分类建议;药帮忙使用单独配置代码,不映射成旧 XYY。若任一新增标识超出现有列长度,迁移时扩列。旧 job 赋旧 flow/version 及唯一 input_id,新组合唯一键对全部历史行先回填再建立。
+
+### 4.7 逐订单推送记录 `JD_ORDER_PUSH`(按现有表复用,缺少时再新增)
+
+文件/操作日志不能替代按订单查询的处理标志,因此该表只保存推送控制与结果,不复制完整平台业务表。
+
+| 字段 | 建议类型 | 用途 |
+| --- | --- | --- |
+| id | BIGINT PK | 推送记录 ID |
+| platform / account_key / source_order_id | VARCHAR(32) / VARCHAR(64) / VARCHAR(128) | 来源订单唯一键,不按文件名去重 |
+| source_order_no / job_id / artifact_id | VARCHAR(128) / VARCHAR(64) / BIGINT | 展示单号、首次导出 job、原始文件关联 |
+| content_hash / request_key | VARCHAR(64) / VARCHAR(128) | 订单内容识别和本次固定请求标识 |
+| push_status | VARCHAR(16) | PENDING、SENDING、ACCEPTED、REJECTED、UNKNOWN |
+| request_body / response_body | JSON | 本次实际结构化发送内容、返回内容或所属批次引用 |
+| receipt_no / error_code / error_message | VARCHAR(128) / VARCHAR(64) / VARCHAR(500) | 回执及失败原因 |
+| attempt_count / next_retry_at | INT / DATETIME | 发送次数及重试时间 |
+| created_at / last_sent_at / accepted_at | DATETIME | 关键时点 |
+
+唯一键 `(platform, account_key, source_order_id)`;相同订单相同内容复用结果,内容变化不能覆盖旧成功记录,记录冲突交运维核对。每次发送详细历史写 job_event,最近状态保存在此表。多个文件含同一订单时,在各导出 job 的日志中记录对应 artifact_id 与台账 id。
+
+接口若支持批量发送,固定请求键绑定整份实际请求体;同键重发内容不变。逐单成功的回执更新对应台账;只发失败子集时生成新请求键并保留与原批次关联。没有逐单回执时按对方整批接收语义记录,不凭本方猜部分成功。
+
+## 5. 详细设计:标志字段与取数条件
+
+### 5.1 状态来源
+
+| 业务标志 | 保存/计算位置 | 何时改变 |
+| --- | --- | --- |
+| 接收是否成功 | IF_REQ.RECEIVE_STATUS | 当前接收请求完成时 |
+| 作业是否登记 | IF_REQ.REGISTER_STATUS | 在 Oracle 中幂等建立本次所需 job 后 |
+| 选品处理结果 | PROCURE job_node.output_json.result | 加购或正常跳过回报时:ADDED / SKIPPED |
+| 合同保存状态 | CONTRACT_CREATE 节点状态及 result | 开始、保存成功、失败或结果未知回报 |
+| 链路更新状态 | CONTRACT_CHAIN_UPDATE 节点状态及 result | 合同保存后调用订单中心,单独记录结果 |
+| 返利状态 | REBATE job.status / current_node_code | 等合同、执行、失败、提交成功 |
+| 平台订单接收状态 | JD_ORDER_PUSH.push_status | 每次发送和回执记录 |
+
+运维接口返回 `contract_save_status / chain_update_status / rebate_status` 三列,是以上权威记录的汇总字段,**不再写回接口表形成第二套状态**。当前节点按 job 指针及该节点最新 attempt 取值,历史成功节点保留,不用整个合同 job.status 代替保存状态。
+
+### 5.2 状态转换
+
+| job 状态 | 含义 | 后续动作 |
+| --- | --- | --- |
+| PENDING | 已登记,等待执行 | 对应脚本领取 |
+| RUNNING | 已被领取且正在执行 | 接收匹配 execution_token 的回报 |
+| SUCCESS | 本流程应做步骤完成 | 常规取数不再领取 |
+| FAILED_RETRYABLE | 明确失败,可重试当前步骤 | 到 next_check_at 后或运维重试,新增 attempt |
+| WAITING_EXTERNAL | 前置尚未完成或结果待核对 | 按 wait_reason 处理,不能一律自动重做 |
+| WAITING_USER / FAILED_MANUAL | 业务或运维需介入 | 在原待办上处理、核对并记录动作 |
+| CANCELLED | 明确停止办理 | 保留原数据和历史 |
+
+wait_reason 建议保存在 job 新增 VARCHAR(32) 字段:WAIT_CONTRACT、PROTOCOL_NOT_READY、RESULT_UNKNOWN。前两种下次检查条件;RESULT_UNKNOWN 需要核对外部结果,确认失败才可重试。
+
+节点复用 PENDING/RUNNING/SUCCESS/FAILED,未知结果用节点 `WAITING_EXTERNAL` 并在 output_json 记 `result=UNKNOWN`;需要新增相应前后端状态支持。未尽调转人工保留待办,不能把“停止自动办理”显示为“合同已保存”。
+
+```mermaid
+stateDiagram-v2
+ [*] --> PENDING
+ PENDING --> RUNNING: 领取
+ RUNNING --> SUCCESS: 完成
+ RUNNING --> FAILED_RETRYABLE: 明确可重试失败
+ RUNNING --> WAITING_EXTERNAL: 前置等待或结果未知
+ RUNNING --> WAITING_USER: 业务处理
+ FAILED_RETRYABLE --> PENDING: 重试当前步骤
+ WAITING_EXTERNAL --> PENDING: 前置满足或核对未成功
+ WAITING_EXTERNAL --> SUCCESS: 核对本流程已完成
+ WAITING_USER --> PENDING: 处理后恢复未完成步骤
+ PENDING --> CANCELLED: 停止办理
+```
+
+*中文对照:`PENDING`=待执行,`RUNNING`=执行中,`SUCCESS`=成功,`FAILED_RETRYABLE`=可重试失败,`WAITING_EXTERNAL`=等待外部结果,`WAITING_USER`=等待人工处理,`CANCELLED`=已取消。*
+
+上图为通用 job 状态。合同保存结果核对成功时若链路尚未更新,实际转移到链路节点 PENDING,不能直接标整个合同 SUCCESS。
+
+### 5.3 领取规则
+
+| flow | 读取范围及条件 | 成功/失败记录 |
+| --- | --- | --- |
+| PROCURE | SALES 数据 READY;分别按 1 号药城、药帮忙的平台配置领取;行 job=PENDING,或到期可重试 | 两个平台使用同一套采购条件;合格 ADDED、不合格 SKIPPED 均结束行 job;错误单独失败 |
+| ORDER_PUSH | 对应平台导出任务及历史未完成台账;ACCEPTED 不重发 | 归档文件及每单 push_status |
+| CONTRACT | ORDER_COMMON 数据 READY;合同 job 待执行,按 current_node 恢复 | CREATE 成功保存合同号;CHAIN 失败只重试 CHAIN |
+| REBATE | 同 INPUT_ID/版本的 CREATE 节点确认 SUCCESS,合同号非空;返利待执行或到期重试 | 从共用接口表读取返利字段;读取合同 job 合同号;提交后 SUCCESS |
+
+新录入与重试均覆盖历史未完成记录,不只筛选当天。创建返利 job 时可处于 WAIT_CONTRACT,合同失败不改它的接口输入;合同成功后,返利下一次扫描将其转 PENDING。
+
+领取使用现有状态中心的领取机制;同一条记录同一时刻只允许一个脚本处理。脚本中断后由运维确认并恢复,不自动启动第二个执行器。
+
+### 5.4 节点定义与订单导出生命周期
+
+| flow | 节点链 | 正常结束条件 |
+| --- | --- | --- |
+| PROCURE | PROCURE_ITEM_RUN | 本行 ADDED 或 SKIPPED |
+| ORDER_PUSH | ORDER_EXPORT_FILE → ORDER_CENTER_PUSH | 文件完整归档后,解析及推送该批应发送订单,均确认接收 |
+| CONTRACT | CONTRACT_CREATE → CONTRACT_CHAIN_UPDATE | 保存及链路更新分别成功;未尽调为当前节点人工待办,不另建供数流程 |
+| REBATE | REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT | 正常提交成功;首次领取前检查合同保存条件 |
+
+ORDER_PUSH 脚本先领取历史未完成批次;再按当前排期需要产生 export_batch_id 并登记新 job(input_id=export_batch_id,input_version=1,source_line_id=空串)。新 job 从 ORDER_EXPORT_FILE 领取执行。导出失败保留该 job;已保存文件和路径写 job_artifact,node_code=ORDER_EXPORT_FILE,恢复时先查已有归档文件。
+
+归档成功后推进 ORDER_CENTER_PUSH:解析文件、按源订单建立/复用 JD_ORDER_PUSH、保存固定请求并推送。本批所有源订单到台账 ID 的关联写入该 job 的节点 output_json(大批量可用 artifact 保存清单),因此同一台账复用于多个文件时仍可判断每个批次是否完成。首次 job_id/artifact_id 不覆盖,后续文件关联由各批次记录持有。
+
+源订单均为 ACCEPTED,或本轮确无可推送订单时,批次 job SUCCESS;零数据结果记 NO_DATA 并显示零条,不创建空订单。部分失败保留当前推送节点,已接收项跳过;同订单不同内容记冲突,阻止该批盲目完成,交运维核对。脚本退出与该批次 job 成功是两件事。
+
+## 6. 详细设计:接口与交互时序
+
+### 6.1.1 平台订单推送中的对码原始数据
+
+平台订单推送给订单中心时,RPA 不传本方判断出的货品编码作为对码结论,而是传订单中心完成判断所需的原始订单信息。每条订单明细至少需要保留并按正式接口字段映射传递:
+
+| 数据组 | 建议字段 | 用途 |
+| --- | --- | --- |
+| 来源标识 | 平台代码、平台账号、源订单号、父订单号(如有)、源订单明细号、导出批次号 | 订单中心识别来源、拆单/明细及幂等关系 |
+| 商品原始信息 | 平台商品编码(如有)、平台品名、批准文号、通用名/商品名称、规格、生产厂家、单位 | 订单中心进行货品对码;平台没有的字段传空并按接口规则处理 |
+| 采购信息 | 实际采购数量、采购单价、订单金额、订单日期、订单状态 | 形成标准采购订单并供后续合同数据使用 |
+| 商家及追溯 | 平台商家/供应商原始标识、仓库、文件名、文件哈希、原始行号 | 追溯和订单关联;文件信息作为来源追踪,不替代业务订单键 |
+
+其中,批准文号、平台品名、通用名/商品名称、规格、生产厂家、单位属于“给订单中心判断用的原始值”,不代表 RPA 已经完成对码。平台订单接口的正式必填字段、字段名称、枚举和缺项规则由订单中心确认;不能只传一个批准文号、数量和金额就宣称对码数据完整。
+
+订单中心处理后至少应能按源订单号/源明细号返回或在合同共用接口数据中提供:标准货品编码、标准货品名称、注册证号、规格、单位,以及合同/返利所需的采购数量、单价、货源细单号等字段。返回方式可以是订单接收后的后续推送或双方约定的查询接口,但必须保持与源订单明细的关联。本方只使用该标准结果填合同和返利,不在本地补做对码。
+
+### 6.1 接口清单
+
+| 接口/方向 | 状态 | 主要契约 |
+| --- | --- | --- |
+| `POST /api/jieda/inbound/sales` | 本方拟新增 | 完整销售对象,接收校验后落表 |
+| `POST /api/jieda/inbound/order-data` | 本方拟新增 | **一次传入合同及返利共同需要的数据**,返回接收记录 ID 和 INPUT_ID |
+| `POST /api/flows/{flow_code}/jobs/claim` | 复用或按现有方式实现 | 按流程领取待处理作业并返回输入关联 |
+| `POST /api/flows/ORDER_PUSH/jobs` | 本方拟新增 | 导出前按 export_batch_id 幂等登记平台/账号/范围及 job,导出失败也有作业可查 |
+| `GET /api/jobs/{job_id}/input` | 本方拟新增 | 对应接口头明细;返利额外返回关联合同 job 及合同号 |
+| `POST /api/jobs/{job_id}/nodes/{node}/{action}` | 复用 start/complete/fail | 保存结果、编号和错误原因 |
+| `POST /api/jobs/{job_id}/push-records` | 按现有回报方式实现 | 保存订单推送结果;不强制新增独立接口 |
+| `POST /api/jobs/{job_id}/retry` | 复用并修正 | 只恢复当前失败步骤,记录操作者及原因 |
+| 平台订单接收 API(本方→订单中心) | 外部契约待提供 | 来源订单、对码原始字段、数量金额及实际接收回执 |
+| 链路节点更新 API(本方→订单中心) | 外部契约待提供 | 来源链路关联、对应节点、已保存合同标识及业务结果 |
+
+订单推送由 ORDER_PUSH 脚本、链路更新由 CONTRACT 脚本通过统一客户端调用。调用前通过状态中心记录固定请求,调用后保存回执。状态中心不同时后台发送同一请求。外部正式路径、业务枚举和鉴权由订单中心提供。
+
+### 6.2 请求、校验及重复处理
+
+接收报文在本方适配层统一表示为 `message_id + objects[]`。每个对象有 source_doc_id、input_version(可选)、header、lines。ORDER_COMMON 的 header/lines 包含第 4.5、7 章共同字段,不是分别推 contract 和 rebate。
+
+| 返回情况 | 本方建议 HTTP | 业务返回及持久化 |
+| --- | --- | --- |
+| 缺字段/格式不合法/空明细 | 422 | REJECTED,errors 含对象标识、行标识及字段路径;无业务头明细 |
+| 全部校验通过且保存成功 | 200 | ACCEPTED、request_log_id、input_ids;表示接收成功 |
+| 已接收同消息同内容 | 200 | DUPLICATE,返回原 input_ids,另记接收尝试 |
+| 已接收同标识不同内容 | 409 | CONFLICT,旧成功输入及作业不变 |
+| 保存失败 | 503 | STORE_FAILED+trace_id;不返回接收成功 |
+| 鉴权失败 | 401/403 | 拒绝,不建立业务作业 |
+
+首版整次请求校验通过后,同一个 Oracle 事务保存成功接收记录及全部对象头明细。任一必填缺失则整次失败,返回全部可识别字段错误;上游可按完整单据分批发送。缺的是返利字段也当次返回,因为这一份数据承担两个流程。
+
+客户类型、三个合同日期、合同明细字段,以及供应商/返利单位/计划员/订单号/货源细单号/含税协议单价在本次共用数据中按第 7 章校验。合同号是本方执行结果,接收时允许不存在。固定值、系统生成字段、页面尽调回显不要求上游提供。
+
+相同业务对象换 message_id 再传,由头表来源+版本唯一键识别:内容相同引用原 INPUT_ID,内容不同冲突。没有源版本号时,哈希只判断内容同异,不自动判定“哪条更新”。已成功接收的同 SOURCE_DOC_ID 改内容或改版本,本期返回 INPUT_CONFLICT,不自动创建第二组合同/返利 job;这是按业务标识检查,不能仅依赖版本唯一索引。运维和订单中心确认改单处理后再另行安排,原作业和结果保持可查。此前校验失败的修正重传可正常接收。
+
+接收失败日志存 IF_REQ;Oracle 本身不可用时只能写应用错误日志和 trace_id,不能宣称数据库必有失败记录。响应在事务提交后产生;若响应丢失,上游重传可取得原接收结果。
+
+### 6.3 时序图:订单中心接收共用数据
+
+订单中心推送完成后只表示本方已接收并保存数据,合同、返利由各自排期执行。
+
+```mermaid
+sequenceDiagram
+ participant OC as 订单中心
+ participant API as 接口服务
+ participant ORA as Oracle
+ OC->>API: 推送合同返利共用数据
+ API->>API: 校验必填字段
+ alt 校验失败
+ API->>ORA: 保存失败记录
+ API-->>OC: 失败及字段位置
+ else 校验通过
+ API->>ORA: 保存接口数据
+ ORA-->>API: 返回 INPUT_ID
+ API-->>OC: 返回接收成功
+ end
+```
+
+### 6.4 时序图:平台订单导出并推送
+
+```mermaid
+sequenceDiagram
+ participant R as 订单脚本
+ participant P as 采购平台
+ participant F as 共享文件夹
+ participant OC as 订单中心
+ participant ORA as Oracle
+ R->>P: 导出订单文件
+ P-->>R: 返回订单文件
+ R->>F: 保存平台订单文件
+ R->>ORA: 保存文件和订单记录
+ R->>OC: 推送订单及对码原始字段
+ alt 接收成功
+ OC-->>R: 返回接收结果
+ R->>ORA: 保存推送成功
+ else 保存失败或接口失败
+ R->>ORA: 保存失败原因
+ end
+```
+订单中心接收平台订单和对码原始字段,订单中心内部完成对码,并在后续共用数据中提供标准货品结果。
+
+### 6.5 时序图:合同排期处理
+
+```mermaid
+sequenceDiagram
+ participant C as 合同脚本
+ participant ORA as Oracle
+ participant CT as 合同系统
+ participant OC as 订单中心
+ C->>ORA: 按合同排期领取待处理数据
+ ORA-->>C: 返回共用数据
+ C->>CT: 录入并保存合同
+ CT-->>C: 返回合同号
+ C->>ORA: 保存合同号和合同结果
+ C->>OC: 更新订单链路
+ OC-->>C: 返回链路结果
+ C->>ORA: 保存链路结果
+```
+合同保存和链路更新分别记录。链路失败时保留合同号,后续只重试链路更新,不重新创建合同。
+
+### 6.6 时序图:返利排期处理
+
+```mermaid
+sequenceDiagram
+ participant R as 返利脚本
+ participant ORA as Oracle
+ participant RB as 返利中心
+ R->>ORA: 按返利排期领取待处理数据
+ ORA->>ORA: 检查同 INPUT_ID 的合同结果
+ alt 合同未保存成功
+ ORA-->>R: 暂不处理,保留等待状态
+ else 合同已保存且合同号存在
+ ORA-->>R: 返回共用数据和合同号
+ R->>RB: 查询协议并提交返利
+ RB-->>R: 返回办理结果
+ R->>ORA: 保存返利结果
+ end
+```
+
+四张图之间的关系是:第一张完成数据接收,第二张完成平台订单推送,第三张和第四张由各自排期独立运行。合同和返利通过同一个 `INPUT_ID` 关联。
+
+## 7. 详细设计:业务字段映射
+
+本章保留 Excel 的实际字段和行号。合同/返利列来源都指向第 4.5 节同一份接口输入;按各自模板生成执行数据。生成的模板、默认日期及映射版本记录到节点 output_json/文件证据,不再复制成独立合同或返利输入表。
+
+### 7.1 合同头
+
+| 目标字段 | 取值规则 | 行号 |
+| --- | --- | --- |
+| 客户名称 | 订单中心 | 12 |
+| 客户编码 | 目标系统自动生成 | 11 |
+| 尽调情况 | 读取回显;未尽调停止本单自动录入并转采购员处理 | 13 |
+| 结算类型 | 收支双向 | 15 |
+| 合同版本类型 | 预付:我司定制;账期:非格式合同 | 17 |
+| 合同金额(单位元) | 预估本次合作金额 | 24 |
+| 金额(元) | 明细汇总;与导入后页面汇总核对并回填 | 25 |
+| 合同签订日期、合同开始日期、合同结束日期 | 订单中心分别提供,三个独立字段 | 26–28 |
+| 是否重大合同 | 否 | 29 |
+| 是否预付款 | 预付:是;账期:否 | 30 |
+| 付款方式 | 电汇 | 32 |
+| 付款期限 | 预付:1;账期:订单中心账期天数,缺失不默认 30 | 33 |
+| 合同具体分类 | 国内药品类、器械类品种 | 37 |
+| 运输方式 | 公路运输 | 39 |
+
+### 7.2 合同明细
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 货品编码 | 订单中心 | 43 |
+| 货品名称 | 订单中心标准货品名称 | 44 |
+| 注册证号 | 订单中心 | 46 |
+| 规格、单位 | 按订单中心标准字段填报 | 47–48 |
+| 数量、单价 | 按订单中心最终采购数量、单价填报 | 49–50 |
+| 付款方式 | 电汇 | 52 |
+| 付款条件 | 订单中心;模板无此字段时不填 | 53 |
+| 付款期限 | 同合同头规则 | 54 |
+
+### 7.3 返利总单
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 返利合约 | 否 | 5 |
+| 供应商编码 | 订单中心 | 6 |
+| 返利单位编码 | 订单中心 | 7 |
+| 计划员 | 订单中心,按业务给定的计划员/采购员对应关系填写 | 8 |
+| 采购结算方式 | 文案“否”;与细单代码 3 分别映射 | 9 |
+| 承付模式 | 下月 N 号结 | 10 |
+| 协议开始日期、协议结束日期 | 当天 | 11–12 |
+
+### 7.4 返利细单
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 货品编码、数量、含税协议单价 | 本次共用接口数据的货品编码、数量及含税协议单价 | 15–17 |
+| 税率、关税税率 | 均为 0.13 | 18–19 |
+| 承付模式、承付参数 | 11、25 | 20–21 |
+| 细单开始日期 | 当天 | 22 |
+| 细单结束日期 | 本年度最后一天 | 23 |
+| 采购结算方式 | 3 | 24 |
+| 特定补偿预估值、进货补偿预估值 | 均为 0 | 25–26 |
+| 细单备注 | 订单中心订单号 | 33 |
+| 货源细单ID | 订单中心货源细单号;与订单号分列 | 34 |
+
+合同明细导入列按 Excel 的 Sheet3。表中标注无需填写的字段不填;系统自动生成字段由系统处理。
+
+
+### 7.5 映射与运行日期规则
+
+- 合同与返利的数量来自同一次报文;实际只提供一列数量时共用该列。含税协议单价与采购单价分别保留来源映射。
+- 首次执行该流程时生成所用默认日期及模板记录。重试已生成的相同步骤使用原输入,避免跨日重试更改已导入单据;新返利作业按返利首次执行当天生成“当天/年底”。这是实现规则,不增加业务确认步骤。
+- 合同客户编码为系统生成字段。现有 RPA 入口要求的 customerCode 实际用作客商搜索标识,须与系统自动生成的客户编码分清;按新页面取数方式适配,不要求上游为自动生成字段填占位符。
+- 映射表标注无需填写的字段不填。尽调读取页面回显;未尽调按原流程转采购员并通知。接口数据保留,人工完成后核对实际合同号并登记保存成功,进入链路更新步骤;返利按第 5.3 节条件领取。
+
+## 8. 详细设计:操作记录与一致性
+
+### 8.1 最少需要记录的操作信息
+
+| 场景 | 保存位置 | 必须能查到的信息 |
+| --- | --- | --- |
+| 每次接口接收 | IF_REQ | 请求时间、来源消息、INPUT_ID 关联、原报文、返回结果、字段错误、trace_id |
+| 脚本执行一步 | job_node | flow、job、INPUT_ID/版本、节点、attempt、run_id、执行机、开始结束、结果、错误 |
+| 平台订单发送一次 | JD_ORDER_PUSH+job_event | 原文件、源订单、固定请求键、实际请求、HTTP/业务返回、耗时、成功/拒绝范围 |
+| 合同保存 | job_node+job_artifact | 实际合同标识、保存确认、目标页面证据、所用输入和模板 |
+| 链路调用一次 | job_event+链路 job_node | 链路关联、节点代码、合同号、请求键、报文、回执、错误 |
+| 运维操作一次 | job_event / user_task | 操作人、时间、动作、原因、前后状态、证据和操作结果 |
+| 文件 | job_artifact | 原始订单、导入模板、截图或日志文件路径,文件类型及哈希 |
+
+job_event 建议事件类型包括 INPUT_BOUND、NODE_STARTED、NODE_SUCCESS、NODE_FAILED、API_REQUEST、API_RESPONSE、RETRY_REQUESTED、RESULT_VERIFIED、MANUAL_HANDOFF、JOB_FINISHED。detail_json 保存结构化信息。节点列表用 job_node.error_code/error_summary 筛选及展示;事件详情从 detail_json.errorCode/errorMessage 读取错误,不假定 job_event 存在独立错误列。详细错误与报文在详情展开。
+
+接收失败没有 job 时由 IF_REQ 独立展示;所有能定位到作业的动作都写 job_event。脚本运行 ID 使用现有 executor_run_id;无数据或启动失败的本轮摘要写应用日志,通过 run_id 查询,暂不新增运行历史表。
+
+### 8.2 事务及恢复顺序
+
+| 写入过程 | 事务范围 | 恢复办法 |
+| --- | --- | --- |
+| 接收成功 | Oracle:成功请求记录+所有接口头明细 | 事务失败整体返回失败;响应丢失可同消息重传 |
+| 登记作业 | Oracle:同输入的合同和返利 job 一起幂等建立 | 成功后回写 IF_REQ.REGISTER_STATUS;回写失败下次重复登记返回同 job,再补 DONE |
+| 节点成功 | Oracle:该 attempt 成功+业务编号+操作日志/证据索引+推进当前节点 | 回报丢失重发同完成键;同成功结果返回原状态,不再次推进 |
+| 外部接口调用 | 先 Oracle 保存请求,事务提交后调用,返回后独立事务记结果 | 超时保留 UNKNOWN,不把网络调用放在数据库事务中 |
+| 人工恢复 | Oracle:核查结果、状态变更和操作日志 | 同一操作请求 ID 重复提交返回原结果 |
+
+接口输入和业务执行结果均在 Oracle 中保存,登记、领取、结果回报使用同一数据库事务边界;外部接口调用仍在事务提交后执行。接口表登记失败时可再次幂等登记,组合唯一约束保证不多建合同/返利作业。
+
+### 8.3 文件和配置
+
+1 药城与药帮忙分别配置共享根目录和字段映射;现场路径由配置提供。下载完成后发布文件,文件名按批次区分,保留原件。后台返回文件列表,业务/运维通过现有证据入口打开或下载,不要求共享盘在所有机器使用同一盘符。
+
+订单中心连接、凭据、超时、链路节点映射、四条排期、重试间隔及模板配置集中管理。真实环境缺必要配置返回明确错误,不自动使用 fixture。日期、数量和金额按第 4、7 章数据类型保存,日志不保存 token 或数据库密码。
+
+## 9. 详细设计:正常路径与异常处理
+
+### 9.1 正常路径示例
+
+本例标识仅用于说明字段关系,不代表正式接口报文。
+
+| 时间/动作 | 接口记录 | 合同保存 | 链路更新 | 返利 | 记录位置 |
+| --- | --- | --- | --- | --- | --- |
+| 接收 M001 | ACCEPTED,INPUT_ID=I001,REGISTER=PENDING | 未登记 | 未登记 | 未登记 | IF_REQ+ORDER_H/L |
+| 任一相关脚本启动登记 | REGISTER=DONE | PENDING | 未开始 | WAIT_CONTRACT | 两个 job 都绑定 I001/1 |
+| 合同脚本处理 | 原输入不变 | RUNNING | 未开始 | WAIT_CONTRACT | CREATE 第 1 次尝试 |
+| 合同保存成功 HT001 | 原输入不变 | SUCCESS | PENDING | 可领取 | 合同号及保存证据 |
+| 更新链路成功 | 原输入不变 | SUCCESS | SUCCESS | 可领取 | 链路请求、回执;合同 job 完成 |
+| 返利下次运行 | 原输入不变 | SUCCESS | SUCCESS | RUNNING | 读取 I001 同一头明细+HT001 |
+| 返利提交完成 FL001 | 原输入不变 | SUCCESS | SUCCESS | SUCCESS | 返利编号、模板及提交结果 |
+
+### 9.2 异常矩阵
+
+| 异常代码(建议) | 识别条件 | 状态和保留内容 | 处理及恢复位置 |
+| --- | --- | --- | --- |
+| INPUT_REQUIRED | 本次共用数据缺合同或返利上游必填 | IF_REQ=REJECTED,字段路径/行号,无业务输入 | 上游补齐后重传;RPA 无需处理 |
+| INPUT_CONFLICT | 成功来源/版本再传不同内容 | CONFLICT,关联旧请求、保存新原文 | 核对来源及版本;不覆盖已使用数据 |
+| STORE_FAILED | 接口表事务失败 | 返回 503;应用日志 trace_id | 修复数据库后重传 |
+| JOB_REGISTER_FAILED | Oracle 已接收,作业登记失败 | REGISTER=FAILED、原因;输入 READY | 修复数据库或服务后,下一次脚本取数补登记 |
+| PLATFORM_QUERY_FAILED | 页面打不开或查询报错 | 行 job 明确失败,截图/错误 | 修复后重试该行;不记 SKIPPED |
+| NO_ELIGIBLE_PRODUCT | 查询成功但不满足采购条件 | job SUCCESS,result=SKIPPED、原因 | 正常结束,常规轮询不重复处理 |
+| FILE_SAVE_FAILED | 共享目录不可写/文件未完整 | ORDER_PUSH 失败、已知路径 | 修复目录后从导出/归档失败处继续 |
+| OC_ORDER_REJECTED | 订单中心明确业务拒收 | push=REJECTED,拒绝原因及范围 | 修正接口映射/源数据后处理失败单,成功单保留 |
+| EXTERNAL_RESULT_UNKNOWN | 加购、保存、推送或提交后无确定结果 | WAITING_EXTERNAL/UNKNOWN,请求或页面证据 | 先核对目标结果;确认未成功才重试 |
+| CONTRACT_DUE_DILIGENCE | 合同页面显示未尽调 | WAITING_USER、采购员待办和页面记录 | 按原业务人工处理,核验保存结果后继续链路 |
+| CHAIN_UPDATE_FAILED | 合同已保存,链路明确失败 | CREATE 成功;CHAIN 失败、回执 | 下次仅重试 CHAIN;合同号不清空 |
+| PROTOCOL_NOT_READY | 已有合同号,但返利协议不可查/暂不可操作 | 返利 WAITING_EXTERNAL,原因及检查时间 | 到期再查协议;不重建合同,不等第二次供数 |
+| REBATE_SUBMIT_FAILED | 返利系统明确提交失败 | 失败步骤、目标协议/总单、已导入信息 | 按原业务交人工处理;保留前面成功步骤 |
+| EXECUTOR_INTERRUPTED | 作业仍 RUNNING,进程已退出 | 执行机/run_id、最近节点、证据 | 运维确认无运行进程并核对结果后,恢复当前步骤 |
+
+所有正常失败恢复保留原 INPUT_ID、job 和历史 attempt。通用“重试”只增加当前失败节点尝试,不把 job 从第一个节点重新执行。已完成外部写入不能仅凭“失败”按钮反向当作没有发生。
+
+## 10. 运维设计:如何查看与处理
+
+### 10.1 查询入口及页面内容
+
+复用现有列表、节点详情、时间线、待办和证据组件,新增接收列表和“同一输入的合同/返利总览”,不用另建运维平台。
+
+| 页面 | 筛选条件 | 列表/详情内容 |
+| --- | --- | --- |
+| 接口接收列表 | 时间、来源单号、MESSAGE_ID、TRACE_ID、接收状态、登记状态 | 请求/返回原文、缺字段路径、INPUT_ID、重复来源、登记失败原因 |
+| 合同返利总览 | INPUT_ID、来源单号、合同号、返利号、三个业务状态、时间 | 同一行显示合同保存、链路更新、返利状态及最近错误;点击进入各 job |
+| 作业详情 | flow、job_id、状态、执行机、run_id | 来源输入、当前节点、每次尝试、耗时、错误、回报、外部编号 |
+| 订单推送列表 | 平台/账号、订单号、导出批次、push_status | 原文件、发送内容、回执、次数、结果未知/拒收原因 |
+| 操作和证据 | job/INPUT_ID、操作者、动作时间 | 谁点击重试/人工处理,前后状态、原因、截图、模板及日志 |
+
+“合同返利总览”由后端以 Oracle INPUT_ID 为基底,关联两份 job/节点结果;不存在 job 时显示未登记。分页/筛选由后端实现,不要求浏览器一次拉全表。
+
+### 10.2 典型查看路径
+
+| 运维问题 | 如何查 | 结论/下一步 |
+| --- | --- | --- |
+| 订单中心说推过,RPA 没处理 | 先查 MESSAGE_ID/来源单号的接收记录 | REJECTED 看缺字段;ACCEPTED+登记失败看数据库错误;DONE 看对应 job |
+| 合同存在,为什么流程显示失败 | 总览看合同保存和链路更新两列 | 保存成功/链路失败只补链路调用,不重建 |
+| 返利为什么一直没开始 | 看同 INPUT_ID 的合同保存结果及返利 wait_reason | 合同未保存则正常等待;保存成功但协议不可查则按目标状态处理 |
+| 同一批有的成功有的失败 | 订单推送按源订单筛选,选品按行 job 查看 | 重试失败项;已接收/已加购项不重复处理 |
+| 数据被改过或重复推过 | 接收尝试记录+DATA_HASH+操作日志 | 明确首次输入、重复/冲突报文及处理人 |
+
+### 10.3 运维动作及权限
+
+| 动作 | 允许条件 | 实际改变 | 必须留痕 |
+| --- | --- | --- | --- |
+| 重试当前步骤 | FAILED_RETRYABLE,且外部结果已明确 | 当前节点新增 attempt,job 转 PENDING | 操作人、原因、旧状态、新 attempt |
+| 重新登记作业 | 已 ACCEPTED 且 REGISTER≠DONE | 幂等补建缺失 job,完成登记标志 | 接收记录、结果、操作者 |
+| 核对结果:已成功 | UNKNOWN 或人工完成,提供实际业务编号/回执证据 | 确认对应节点成功并推进;合同保存后仍执行链路 | 目标编号、证据、核对人和时间 |
+| 核对结果:未成功 | 确认目标未写入,原执行进程已停止 | 转可重试当前步骤 | 核对依据、操作者、原因 |
+| 转业务处理 | 业务错误或系统要求人工 | 创建/复用现有 user_task,保存责任人 | 原因、接收人、操作记录 |
+| 停止办理 | 有明确业务指令 | CANCELLED,保留已产生结果 | 原因及外部已发生操作,不自动撤销外部单据 |
+
+只读运维可看列表及证据;恢复动作限定 OPS,业务待办按采购员等现有角色处理。恢复入口必须核对实际操作者身份,不能只相信请求体里填写的 operator。沿用现有鉴权方案补动作权限,不另加业务审批流。
+
+不能用“统一确认并继续”替代所有核对动作,不能直接改接口原始字段或数据库状态。金额、货品等源数据错误由订单中心修正重传;已被使用的输入按冲突规则处理。
+
+### 10.4 查询 API 及日志定位
+
+| API(建议) | 用途 |
+| --- | --- |
+| `GET /api/jieda/receipts`、`/receipts/{request_log_id}` | 无 job 的失败接收也能查 |
+| `GET /api/jieda/orders`、`/orders/{input_id}` | 同一输入的合同/链路/返利总览及头明细 |
+| `GET /api/jieda/order-pushes` | 按源订单查推送及文件 |
+| 现有 `GET /api/jobs/{job_id}`、`/timeline`、`/evidence-package` | 节点、历史尝试和证据 |
+| `GET /api/jobs/{job_id}/events`(新增) | 操作日志和出站请求/返回记录 |
+| `POST /api/jieda/receipts/{id}/register`(新增) | 运维手动补登记,与脚本取数复用同一逻辑 |
+| `POST /api/jobs/{job_id}/verify-result`(新增) | 按第 10.3 节核对 UNKNOWN/人工结果,记录依据后恢复 |
+
+应用日志统一携带 `trace_id、input_id、job_id、flow_code、node_code、attempt_no、run_id` 中当前已知项。接收阶段没有 job_id 时不编造;执行阶段没有 HTTP trace 时以 job/run 定位。列表展示最近错误,详情保留全部历史,不只覆盖最后一次失败。
+
+索引建议:接口记录按时间/状态/来源,业务头按源单号,job 按 `(flow_code,status,next_check_at)` 和 `(input_id,input_version)`,节点按现有 `(job_id,node_code,attempt_no)`,事件按 `(job_id,created_at)`,推送按来源唯一键及 `(push_status,next_retry_at)`。
+
+## 11. 实施影响与验收
+
+### 11.1 现有代码到新设计的映射
+
+| 现有位置 | 已核对现状 | 实施改动 |
+| --- | --- | --- |
+| [main.py 建单](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:499) | 供应商+日期键,固定首节点,只复用未完成作业 | 新接收/登记/领取按 flow 和 INPUT_ID;已完成作业也防重 |
+| [schema.sql](/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql:11) | job 无 flow/input 关联;已有节点、日志及证据表 | 第 4 章 Oracle 接口表及推送台账;job 扩展字段/索引,supplier_name/order_date 允许空;job_event 错误保存在现有 detail_json |
+| [db.py](/Users/yuuuu/Documents/AutoRpa/state_center/app/db.py:22) | 当前数据库访问实现 | 按 Oracle 驱动和事务方式调整登记、领取、结果及推进 |
+| [main.py 完成回报](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:712) | 保存节点、编号和推进分步执行;节点定义全局固定 | 同事务回报,校验 execution_token/attempt;按 flow 定义完成规则 |
+| [main.py 重试](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:812) | 已保留 attempt 历史、重试当前节点 | 保留机制,支持共用输入关联及 UNKNOWN 核对 |
+| [main.py 人工提交](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:1346) | 通用确认可直接推进;RETRY_CURRENT_NODE 分支当前只记录事件 | 按第 10.3 节校验结果和动作,实现真实重试及核查恢复 |
+| [agent.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/agent.py:1) | 通用代理主要调用 mock;存在节点硬编码与默认 attempt=1 | 四条独立入口、真实 runner、领取结果中的 attempt/token |
+| [contract_create_node.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/contract_create_node.py:241) | 已校验空明细及正数量,仍要求旧 customerCode | 适配共用接口数据和客商定位;保留有效输入检查 |
+| [contract_flow_steps.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/contract_flow_steps.py:788) | 缺价补 1.00,同码首行及前 30 行定位 | 移除缺价补值;按来源明细/目标行定位并核对实际导入结果 |
+| [Web 节点详情](/Users/yuuuu/Documents/AutoRpa/state_center_web/src/components/JobNodeDetailList.tsx:1) | 已展示时间、尝试次数、错误及证据 | 复用组件,补三个独立状态、输入关联、接收列表及恢复动作 |
+
+当前 Oracle 配置主要服务货品主数据读取;新接口表写入连接和权限需现场配置。当前代码不等于已具备四条真实平台执行能力,平台查询/导出及返利页面模块需要核对或实现。
+
+### 11.2 实施顺序
+
+1. 按共同输入契约核对第 4、7 章字段及合同/返利范围;确认现场接口表复用方式。
+2. 完成接收校验、Oracle 同事务保存及接收查询,验证失败重传和重复消息。
+3. 增加 flow/input 关联、作业登记、独立状态、领取及结果事务,跑通“同一输入两个 job”。
+4. 接平台订单导出、共享文件、台账及推送回执。
+5. 接合同页面保存和链路更新;返利读取同 INPUT_ID 与合同号完成提交。
+6. 接逐条选品;补齐异常及运维动作,配置四条排期后联调。
+
+### 11.3 验收用例
+
+| 编号 | 场景 | 预期结果 |
+| --- | --- | --- |
+| AT-01 | 一次完整 ORDER_COMMON 推送 | 一份头明细;登记两个相同 INPUT_ID/版本的合同、返利 job |
+| AT-02 | 仅返利必填缺失 | 当次返回字段错误,无业务输入;补齐后可重传 |
+| AT-03 | 相同请求重发、相同对象换消息 ID 重发 | 返回原输入,不新增执行作业 |
+| AT-04 | 同已成功来源/版本传不同内容 | 冲突可查,旧数据和外部结果不被覆盖 |
+| AT-05 | 接收已提交,作业登记失败 | 接收记录显示登记失败;后续幂等补登记,不要求上游再推 |
+| AT-06 | 返利脚本先于合同脚本启动 | WAIT_CONTRACT;无额外供数请求、无返利提交 |
+| AT-07 | 合同保存成功,链路更新失败 | 保存 SUCCESS、链路 FAILED;只恢复链路,返利可按保存条件领取 |
+| AT-08 | 合同完成后返利按另一排期启动 | 读取同一接口数据和本方合同号,字段来源可追溯 |
+| AT-09 | 平台订单重复导出到不同文件 | 原件保留,已接收同源订单不重复入单 |
+| AT-10 | 推送部分成功/超时 | 按实际回执记录逐单结果;UNKNOWN 不直接按失败重发 |
+| AT-11 | 选品合格/不合格/查询失败 | 分别 ADDED、SKIPPED、FAILED;本批结束,不等付款 |
+| AT-12 | 脚本中断和重复点击 | 不重复领取;已有结果可查,确认停止并核对后恢复当前步骤 |
+| AT-13 | 合同已保存但回报丢失 | 重发完成键或核对目标结果,不再次建合同 |
+| AT-14 | 未尽调或返利提交失败 | 保留输入及原业务待办,其他记录继续;人工结果核对可留痕 |
+| AT-15 | 46 个 Excel RPA 字段 | 共用接口来源、固定值及日期按映射填报,缺价不补 1.00 |
+| AT-16 | 运维按 MESSAGE_ID、INPUT_ID、合同号查询 | 能串起接收、两流程、尝试、文件、请求和人工动作 |
+| AT-17 | 未授权恢复、旧 token 回报 | 拒绝写入;旧成功结果不被覆盖 |
+| AT-18 | 新旧流程并存 | 旧在途单保留原节点;新流程不误入对码/OCR/M5 |
+
+文档检查与业务验收分开:当前只完成设计;以上需要在测试数据库、实际共享目录及外部测试系统执行,mock 成功不代表真实业务通过。
+
+## 12. 未决事项、风险及对其他模块的影响
+
+### 12.1 联调前需要确认
+
+| 项目 | 已明确 | 剩余信息 |
+| --- | --- | --- |
+| 合同返利供数 | 一次推送共用 | 正式头明细、业务范围标识、消息 ID/版本;一组数据是否有多合同/多返利范围 |
+| 业务字段 | Excel 来源及默认值 | 实际 JSON 列名;采购/返利数量是否同字段,价格口径及精度 |
+| 订单出站接口及对码字段 | 本方归档后推送原始订单字段 | 确认批准文号、品名、通用名、规格、厂家、单位等字段是否必填,以及标准货品结果的返回/后续供数方式 |
+| 链路更新 | 合同保存成功后更新对应节点 | 节点代码、链路关联、合同标识字段及成功返回码 |
+| 返利可操作性 | 用同一份输入加已保存合同号 | 目标协议生成时间、是否依赖审批或链路更新;未确认前不新增业务审批门槛 |
+| 平台和共享目录 | 两平台分别导出归档 | 真实账号、订单状态/范围、文件样例、共享路径及四条排期 |
+| 盈利 | 5% 以上盈利 | 计算基数及边界;不直接套用旧方案 0.95 公式 |
+| 现场库和模板 | 接口输入与处理记录分开 | 现有 Oracle 表、权限、正式模板和确有要求的附件 |
+
+### 12.2 可能问题与控制位置
+
+| 问题 | 影响 | 对应设计 |
+| --- | --- | --- |
+| 共用一个 processed 标志 | 合同处理后返利漏取 | 第 5 章分离合同保存、链路、返利标志 |
+| 两份接口输入被分别修改 | 合同/返利货品数量不一致 | 同 INPUT_ID/版本只保留一份原始头明细 |
+| 成功接收后没有 job | 数据落表但长期未执行 | REGISTER_STATUS 和取数前补登记;运维可查 |
+| 接口/页面结果未知被当失败 | 重复订单、合同或返利 | UNKNOWN 核对后恢复,不无条件重做 |
+| 把链路失败当合同未保存 | 再建一份合同 | 保存节点结果与链路节点独立 |
+| 当前合同缺价补值或明细定位错误 | 错金额、同码行覆盖或漏行 | 接收校验及第 11.1 节代码修正;单规格规则不消除多订单行的问题 |
+| 原人工确认直接推进 | 未核实外部结果就完成作业 | 第 10.3 节按动作和证据校验 |
+| 新旧流程改全局状态机 | 旧对码、OCR、M5 在途单无法继续 | flow/version 分开,旧单保持原链;切换前确定同一来源由哪套办理 |
+| 两库或共享目录不可用 | 接收失败、原文不可查、文件不可达 | 独立错误可见;执行记录保留,不伪装成空数据或成功 |
+
+本稿的表与字段是详细设计建议,尚未核对现场字典。对外契约尚未提供的部分保留为明确缺口;不以增加业务操作、复制数据或自动猜规则来填补。
diff --git a/docs/superpowers/specs/2026-09-13-jieda-rpa-integrated-design.md b/docs/superpowers/specs/2026-09-13-jieda-rpa-integrated-design.md
new file mode 100644
index 0000000..f82e167
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-13-jieda-rpa-integrated-design.md
@@ -0,0 +1,781 @@
+# 福建洁达采购 RPA 概要设计与详细设计
+
+| 文档属性 | 内容 |
+| --- | --- |
+| 版本 | V3.0 |
+| 日期 | 2026-09-13 |
+| 状态 | 设计评审稿;未实施代码或建表 |
+| 适用系统 | 当前 AutoRpa 状态中心、RPA 执行程序及福建洁达采购业务 |
+| 本版核心关系 | 订单中心一次推送合同/返利共用数据;合同先处理,返利按自己的排期读取同一份数据 |
+| 文档范围 | 概要设计、表与状态详细设计、接口及时序、异常恢复、运维查询、实施与验收 |
+| 替代关系 | 整合原 Codex 设计稿 V1.9 和技术实现说明 V2.3;旧方案仅供历史参考 |
+
+**阅读顺序:** 业务评审看第 1–3 章;开发看第 4–9 章;运维看第 10 章;实施验收及未决问题见第 11–12 章。
+
+## 1. 设计依据与范围
+
+### 1.1 已确认业务规则
+
+| 编号 | 规则 |
+| --- | --- |
+| BR-01 | 四条脚本分别运行,自动时间和频率不同,也支持业务点击;一次 HTTP 接收不等待四条流程全部执行 |
+| BR-02 | 选品读取未处理销售明细,按批准文号查询;合格加购物车,不合格跳过,处理完本批结束;不等待人工付款 |
+| BR-03 | 本业务相同批准文号只有一个规格,不增加规格、厂家、包装或单位换算匹配 |
+| BR-04 | 平台订单导出到各平台对应共享文件夹,按订单中心接口格式组装并推送;对码由订单中心内部处理 |
+| BR-05 | 订单中心一次传来的完整业务数据供合同、返利共用,不拆成两次独立供数,不重复保存两份相同头明细 |
+| BR-06 | 接收时检查上游应传的必填字段,缺项当次返回失败和字段位置;通过并落表后返回成功 |
+| BR-07 | 合同保存成功后调用订单中心链路接口更新对应节点;返利读取同一份接口数据,并使用本方已取得的合同号 |
+| BR-08 | 手工单暂不做;合同/返利字段、固定值及原业务人工分支按提供材料执行 |
+
+### 1.2 参考材料
+
+| 材料 | 用途 |
+| --- | --- |
+| [合同系统录入流程](/Users/yuuuu/Documents/AutoRpa/资料/合同系统录入流程.md) | 合单、尽调、提交、回写;页面步骤指向 rpa_yisaiqi |
+| [选品条件 20260902](/Users/yuuuu/Documents/AutoRpa/资料/采购平台选品条件优先项_20260902.txt) | 自营仓、盈利、效期、库存和仓库顺序 |
+| [合同返利字段映射 20260911](/Users/yuuuu/Documents/AutoRpa/资料/合同返利系统字段映射_20260911.xlsx) | 两个工作表的 46 个 RPA 字段及 Sheet3 导入列 |
+| [仓库一期技术设计](/Users/yuuuu/Documents/AutoRpa/资料/采购主链RPA一期设计文档.md) | 参考概要/详细设计、实体、接口、状态和审计的组织方式;不沿用其旧业务范围 |
+| [现有数据库结构](/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql) | 判断 job、节点、待办、日志及证据记录可复用部分 |
+
+本次会话的业务确认优先于旧文档。下文表名、内部代码、API 路径及列类型为本方建议,正式外部字段名、节点代码和现场 Oracle 结构仍需按第 12 章核对。
+
+## 2. 概要设计:架构与分工
+
+### 2.1 总体架构
+
+```mermaid
+flowchart TB
+ OC[订单中心] -->|销售数据 / 合同返利共用数据| API[FastAPI 接收校验]
+ API -->|校验成功同事务落表| IFDB[(Oracle 接口头明细及接收记录)]
+ S[四条独立排期 / 点击脚本] --> RPA[RPA 执行程序]
+ RPA <-->|领取数据 / 回报结果| SC[状态中心]
+ SC -->|读取同一份输入| IFDB
+ SC <--> BDB[(Oracle 作业、节点、调用记录)]
+ RPA -->|查品加购 / 导出订单| PL[采购平台]
+ PL --> F[对应共享文件夹]
+ F -->|组装报文并推送| OC
+ RPA -->|录入保存| CT[合同系统]
+ RPA -->|保存后调用链路接口| OC
+ RPA -->|同一接口数据加合同号| RB[返利中心]
+ UI[运维列表 / 详情 / 日志] --> SC
+```
+
+接口数据与业务处理记录分开:**Oracle 接口表回答“订单中心传了什么”,Oracle 业务记录回答“本方做到哪一步、结果是什么、谁操作过”。** RPA 通过状态中心读取接口数据,数据库连接集中在后端。
+
+### 2.2 接口表与业务表的选择
+
+| 方式 | 特点 | 本期选择 |
+| --- | --- | --- |
+| 仅一张接口表加一个 processed 标志 | 简单,但合同处理后可能被整体标记完成,返利漏取;也无法表达多次失败尝试 | 不采用 |
+| 接口表再完整复制成合同表、返利表 | 三份字段容易不一致,需要额外同步 | 不采用 |
+| 接口头明细保留一份输入,业务作业/节点保存独立状态与结果 | 合同、返利通过相同 INPUT_ID 关联;历史操作复用现有日志 | **采用** |
+
+现有 `job / job_node` 就是本期业务处理表,无需再复制合同、返利输入。为无 job 的接收失败补接收记录;为跨导出文件识别同一订单,补逐订单推送台账。这两项有明确查询及防重用途。
+
+### 2.3 模块职责及运行方式
+
+| 模块 | 职责 |
+| --- | --- |
+| FastAPI | 校验、保存接收数据、取数、原子领取、保存回报、查询及运维动作 |
+| Oracle 接口库 | 接收尝试、销售头明细、合同/返利共用头明细;成功输入保留原值 |
+| Oracle 状态中心 | 各流程 job、节点尝试、业务编号、订单推送状态、操作日志、证据索引 |
+| 四条 RPA 脚本 | 本批逐条执行;平台订单和合同链路接口由对应脚本通过适配器调用并回报 |
+| 现有 Web | 接收查询、共用数据处理总览、节点尝试、文件和运维动作 |
+
+四条脚本分别配置时间、频率及本轮条数上限,单执行机每条脚本设单实例启动控制。FastAPI 常驻。脚本启动时调用后端登记未建立的 job,再领取本流程数据;订单导出先生成并登记 export_batch_id,恢复失败批次沿用该 ID;本批结束释放本机运行锁。没有数据时记录本轮零条并退出,不新增第五条业务或后台维护流程。
+
+## 3. 概要设计:业务流程与完成边界
+
+| 流程 | 输入 | 正常处理 | 完成含义 |
+| --- | --- | --- | --- |
+| PROCURE | 销售接口表的一条明细 | 按批准文号查询,按采购条件加购或跳过 | 该行处理完;不代表付款成交 |
+| ORDER_PUSH | 对应平台导出的订单文件 | 保存共享目录、解析、组装、推送、记录回执 | 应推送订单确认接收;不等待内部对码 |
+| CONTRACT | 共用订单接口头明细 | 录入并保存合同,保存合同号,更新订单中心链路 | 保存及链路更新分别记录;链路失败不重建合同 |
+| REBATE | **同一共用订单接口头明细**+本方合同保存结果 | 按合同号查协议、填总单、导细单、提交 | 返利提交成功;不要求订单中心再次推数据 |
+
+### 3.1 合同与返利的关联
+
+```mermaid
+flowchart TD
+ I[一次推送:INPUT_ID = I001] --> C[合同 job:INPUT_ID = I001]
+ I --> R[返利 job:INPUT_ID = I001]
+ C --> CS[合同保存成功:合同号 HT001]
+ CS --> CH[合同链路更新]
+ CS -->|提供合同号| R
+ R --> RS[返利提交结果]
+```
+
+合同、返利是两个独立作业,绑定同一 INPUT_ID 和 INPUT_VERSION。合同保存号只从对应合同 job 取得,不能按同品种或同一天猜配。
+
+返利默认在“合同已确认保存成功且合同号存在”后可领取;链路回传结果另列显示,**不擅自把链路成功增加为返利前置条件**。若目标返利协议暂不可查,记录等待原因,下次返利运行再查询。是否必须审批通过由目标系统及接口方确认,不新增审批动作。
+
+### 3.2 选品规则
+
+自营仓、5% 以上盈利、有效期大于等于 12 个月、库存满足采购数量四项都通过后,按下列仓库顺序选择。不合格记跳过,平台技术错误记失败。
+
+广东壹号药业有限公司、昆山亿方药业有限公司、湖北亿昊药业有限公司、天津壹号药业有限公司、重庆亿昊药业有限公司、重庆壹号医药有限公司、湖南壹号药业股份有限公司、辽宁亿昊药业有限公司、河南壹号医药有限公司、甘肃壹号云医药有限责任公司企业店、陕西壹号药业有限公司。
+
+## 4. 详细设计:数据模型
+
+### 4.1 实体关系和存储位置
+
+```mermaid
+erDiagram
+ RPA_JD_IF_REQ ||--o{ RPA_JD_SALES_H : accepts
+ RPA_JD_IF_REQ ||--o{ RPA_JD_ORDER_H : accepts
+ RPA_JD_SALES_H ||--|{ RPA_JD_SALES_L : contains
+ RPA_JD_ORDER_H ||--|{ RPA_JD_ORDER_L : contains
+ RPA_JD_SALES_L ||--o{ JOB : procure
+ RPA_JD_ORDER_H ||--o{ JOB : contract_and_rebate
+ JOB ||--o{ JOB_NODE : attempts
+ JOB ||--o{ JOB_EVENT : operations
+ JOB ||--o{ JOB_ARTIFACT : files
+ JOB ||--o{ JD_ORDER_PUSH : exports
+```
+
+前五张为 Oracle 逻辑表:一张接收记录、销售头明细、共用订单头明细;已有同用途表优先映射复用。其余为 Oracle,`JD_ORDER_PUSH` 拟新增,其余复用。图中的同库关系是应用关联,不是跨数据库外键。
+
+接口表内的合同/返利状态不各写一份。运维总览按 INPUT_ID 汇总两份 job 与其节点,展示三个独立标志:合同保存、链路更新、返利办理。读写来源见第 5 章。
+
+### 4.2 接收记录 `RPA_JD_IF_REQ`
+
+一行是一回接收尝试;错误请求也能查到,不能依赖先有 job 才记录。
+
+| 字段 | 建议类型 | 含义与约束 |
+| --- | --- | --- |
+| REQUEST_LOG_ID | VARCHAR2(64) PK | 本方生成,返回给上游和运维定位 |
+| TRACE_ID | VARCHAR2(64) | 本次请求关联应用日志 |
+| SOURCE_SYSTEM / MESSAGE_ID | VARCHAR2(32) / VARCHAR2(128) | 调用方及其请求标识;MESSAGE_ID 格式由对方提供 |
+| DATA_TYPE | VARCHAR2(24) | SALES 或 ORDER_COMMON;没有独立 REBATE 接收类型 |
+| BODY_HASH | VARCHAR2(64) | 规范化业务报文哈希,识别同请求内容是否一致 |
+| RECEIVE_STATUS | VARCHAR2(16) | ACCEPTED、REJECTED、DUPLICATE、CONFLICT |
+| ORIGINAL_REQ_ID | VARCHAR2(64),可空 | 重复请求关联首次成功记录 |
+| RAW_BODY / RESPONSE_BODY | CLOB | 实际业务报文和返回内容,不记录鉴权口令 |
+| ERROR_CODE / ERROR_DETAIL | VARCHAR2(64) / CLOB | 字段路径、明细标识、错误说明 |
+| RECEIVED_AT / RESPONDED_AT | TIMESTAMP | 收到及生成返回结果的时间 |
+| REGISTER_STATUS | VARCHAR2(8),可空 | 成功记录的作业登记标志:PENDING、DONE、FAILED;非成功记录为空 |
+| REGISTER_ERROR / REGISTERED_AT | VARCHAR2(500) / TIMESTAMP | Oracle 登记失败原因及最终登记时间 |
+
+RECEIVE_STATUS 表示接收事实,REGISTER_STATUS 仅表示是否已登记作业,均不代表合同或返利完成。成功接收同事务保存接口头明细并置 REGISTER_STATUS=PENDING;脚本取数前补登记,完成后更新 DONE。登记失败在下一次脚本取数重试,无需上游重传。
+
+约束:对 ACCEPTED 记录建立 `(SOURCE_SYSTEM, MESSAGE_ID)` 条件唯一索引;Oracle 可用 CASE 表达式索引实现。失败/重复尝试保留独立行,不占用成功消息键。索引还包括 `(DATA_TYPE, REGISTER_STATUS, RECEIVED_AT)`、`TRACE_ID`、`(SOURCE_SYSTEM, MESSAGE_ID)`。
+
+### 4.3 接口头公共字段
+
+销售头和共用订单头均使用以下控制列。
+
+| 字段 | 建议类型 | 用途 |
+| --- | --- | --- |
+| INPUT_ID | VARCHAR2(64) PK | 本方输入身份,跨流程统一关联 |
+| REQUEST_LOG_ID | VARCHAR2(64) FK | 首次成功接收记录 |
+| SOURCE_SYSTEM / SOURCE_DOC_ID | VARCHAR2(32) / VARCHAR2(128) | 上游稳定业务标识;与展示单号分开 |
+| SOURCE_DOC_NO | VARCHAR2(128) | 业务查询展示用单号 |
+| INPUT_VERSION | VARCHAR2(64) | 上游版本;未提供时首版用固定 1,变更走第 6.2 节冲突规则 |
+| DATA_HASH | VARCHAR2(64) | 本对象完整头明细内容哈希 |
+| DATA_STATUS | VARCHAR2(16) | READY:成功接收的完整输入;本期不另设待补字段状态 |
+| CREATED_AT | TIMESTAMP | 成功保存时间 |
+
+头表唯一键为 `(SOURCE_SYSTEM, SOURCE_DOC_ID, INPUT_VERSION)`。明细以 `(INPUT_ID, SOURCE_LINE_ID)` 唯一,关联本头,不以货品编码唯一。成功输入不由 RPA 改写,处理结果另存 Oracle。
+
+### 4.4 销售接口头明细 `RPA_JD_SALES_H / L`
+
+| 表 | 业务字段 | 用途 |
+| --- | --- | --- |
+| SALES_H | 第 4.3 节公共字段、BUSINESS_DATE | 一份销售总单 |
+| SALES_L | LINE_ID、INPUT_ID、SOURCE_LINE_ID、APPROVAL_NO、PURCHASE_QTY、SALES_PRICE | 逐条查询所需批准文号、需求采购数量、销售价格 |
+| SALES_L 可选描述 | GOODS_NAME、SPEC、MANUFACTURER、UNIT | 按上游原值保留供查看,不作为新增属性匹配门槛 |
+
+LINE_ID 建议 VARCHAR2(64),数量 NUMBER(18,4),价格 NUMBER(18,6),标识/文号 VARCHAR2(128),描述 VARCHAR2(256)。最终长度和精度按真实接口及目标系统核对。
+
+### 4.5 合同/返利共用接口头明细 `RPA_JD_ORDER_H / L`
+
+一份头代表上游明确给定的一组合同及对应返利数据,范围由报文的 SOURCE_DOC_ID 和明细集合确定。首版不按供应商+日期自行合单。若实际存在一组数据对应多份合同/返利,需要上游给出组标识,见第 12 章。
+
+| 位置 | 建议字段 | 含义/使用流程 |
+| --- | --- | --- |
+| ORDER_H | 第 4.3 节公共字段 | 合同和返利共用输入标识 |
+| ORDER_H | CUSTOMER_NAME、CUSTOMER_TYPE、CREDIT_DAYS | 合同客户名称、预付/账期类型、账期天数 |
+| ORDER_H | CONTRACT_SIGN_DATE、CONTRACT_START_DATE、CONTRACT_END_DATE | 三个独立合同日期 |
+| ORDER_H | SUPPLIER_CODE、REBATE_UNIT_CODE、PLANNER、PURCHASER | 返利供应商、返利单位、计划员及采购员对应信息 |
+| ORDER_H | CHAIN_REF | 订单中心链路/源业务关联;精确结构按正式接口 |
+| ORDER_L | LINE_ID、INPUT_ID、SOURCE_LINE_ID、ORDER_NO、SOURCE_DETAIL_ID | 本方行 ID、来源行、订单号、货源细单号;订单号与货源细单号分列 |
+| ORDER_L | GOODS_CODE、GOODS_NAME、REG_CERT_NO、SPEC、UNIT | 标准货品编码/名称、注册证号、规格、单位 |
+| ORDER_L | PURCHASE_QTY、PURCHASE_UNIT_PRICE | 采购数量和单价;用于合同明细 |
+| ORDER_L | REBATE_QTY、REBATE_TAX_PRICE | 返利数量和含税协议单价;用于返利明细 |
+| ORDER_L | PAYMENT_CONDITION,可空 | 实际合同模板需要时从同一报文提供 |
+
+同一次报文中数量如只有一个来源字段且供两流程共用,映射层同时引用它;不因此要求上游重复传两个数量。采购单价与含税协议单价按 Excel 的不同含义分别映射,未经接口确认不互相代替。
+
+建议数量 NUMBER(18,4)、价格 NUMBER(18,6)、金额 NUMBER(18,2)、日期 DATE;名称 VARCHAR2(256)、编码 VARCHAR2(128)、类型 VARCHAR2(16)、账期 NUMBER(6)、CHAIN_REF CLOB 或按正式关联键拆列。固定默认值不强迫订单中心传入,在节点执行输入中生成并记录所用模板/映射版本。
+
+**此处没有 `RPA_JD_CONTRACT_HEAD / RPA_JD_REBATE_HEAD` 两套输入表。两流程取数的 INPUT_ID 必须相同。**
+
+### 4.6 业务作业与节点记录
+
+| 表/字段 | 现有或新增 | 详细用途 |
+| --- | --- | --- |
+| job.flow_code / flow_version | 新增 VARCHAR(24) / VARCHAR(16) | 旧作业归原主链,新作业区分四条流程 |
+| job.input_id / input_version | 新增 VARCHAR(64) | 指向 Oracle 成功输入;ORDER_PUSH 指向本方导出批次 ID |
+| job.source_line_id | 新增 VARCHAR(128),头级作业用空串 | PROCURE 对应一条销售明细;合同/返利对应头 |
+| job.business_key | 复用 | 改为上述来源关联的展示/定位键;防重以组合唯一约束为准 |
+| job.status / current_node_code | 复用 | 当前执行状态和恢复节点 |
+| job.contract_system_no / rebate_order_no | 复用 | 合同 job 保存合同号,返利 job 保存返利总单/协议编号 |
+| job.next_check_at | 复用 | 可重试失败或外部等待的下次处理时间 |
+| job.execution_token / execution_host | 新增 VARCHAR(64) / VARCHAR(128) | 当前领取身份,拒绝旧执行回报;记录实际执行机 |
+| job_node.attempt_no、status、output_json | 复用 | 每次尝试独立记录;输入引用、业务结果和外部编号 |
+| job_node.started_at / ended_at / duration_ms | 复用 | 操作开始结束及耗时 |
+| job_node.error_code / error_summary | 复用 | 可筛选错误分类和具体原因 |
+| job_node.executor_run_id / callback_idempotency_key | 复用 | 一次脚本运行 ID、稳定回报键,重发回报不重复推进 |
+
+新 flow 的组合唯一约束:`(flow_code, input_id, input_version, source_line_id)`;已成功作业也受约束。flow_version 是实现规则版本,不因升级自动为旧输入创建第二份作业。旧 job 回填其自身唯一 input_id,保留原入口规则。
+
+共用数据成功接收后登记两份 job:CONTRACT 初始 PENDING,REBATE 初始 WAITING_EXTERNAL,原因为 WAIT_CONTRACT。同一 Oracle 输入即便登记调用重复,也只建立这一对作业。一次接收多组业务对象则逐头建立,不共享一个总完成标志。
+
+旧列兼容迁移:保留 job_id、business_key、source_type、current_node_code、status、created_by 非空;supplier_name 和 order_date 改为允许空值,新来源数据有值时填入。旧入口继续校验其原必填,不给新作业补造供应商或日期。business_key 由新唯一键各项组合生成可读/哈希值;created_by 记录业务启动人或计划任务账号。
+
+| flow | source_type | supplier_name / order_date | 首节点 |
+| --- | --- | --- | --- |
+| PROCURE | 本方定义 ORDER_CENTER | 供应商为空;日期取销售业务日(若提供) | PROCURE_ITEM_RUN |
+| ORDER_PUSH | 明确的平台配置代码 | 文件批次可含多供应商,供应商为空;日期不强制单日 | ORDER_EXPORT_FILE |
+| CONTRACT / REBATE | ORDER_CENTER | 接口明确提供的展示供应商及业务日期;合同三个日期仍取接口独立字段 | CONTRACT_CREATE / REBATE_HEADER |
+
+source_type 上述取值是本方分类建议;药帮忙使用单独配置代码,不映射成旧 XYY。若任一新增标识超出现有列长度,迁移时扩列。旧 job 赋旧 flow/version 及唯一 input_id,新组合唯一键对全部历史行先回填再建立。
+
+### 4.7 逐订单推送台账 `JD_ORDER_PUSH`(Oracle 拟新增)
+
+文件/操作日志不能替代按订单查询的处理标志,因此该表只保存推送控制与结果,不复制完整平台业务表。
+
+| 字段 | 建议类型 | 用途 |
+| --- | --- | --- |
+| id | BIGINT PK | 推送记录 ID |
+| platform / account_key / source_order_id | VARCHAR(32) / VARCHAR(64) / VARCHAR(128) | 来源订单唯一键,不按文件名去重 |
+| source_order_no / job_id / artifact_id | VARCHAR(128) / VARCHAR(64) / BIGINT | 展示单号、首次导出 job、原始文件关联 |
+| content_hash / request_key | VARCHAR(64) / VARCHAR(128) | 订单内容识别和本次固定请求标识 |
+| push_status | VARCHAR(16) | PENDING、SENDING、ACCEPTED、REJECTED、UNKNOWN |
+| request_body / response_body | JSON | 本次实际结构化发送内容、返回内容或所属批次引用 |
+| receipt_no / error_code / error_message | VARCHAR(128) / VARCHAR(64) / VARCHAR(500) | 回执及失败原因 |
+| attempt_count / next_retry_at | INT / DATETIME | 发送次数及重试时间 |
+| created_at / last_sent_at / accepted_at | DATETIME | 关键时点 |
+
+唯一键 `(platform, account_key, source_order_id)`;相同订单相同内容复用结果,内容变化不能覆盖旧成功记录,记录冲突交运维核对。每次发送详细历史写 job_event,最近状态保存在此表。多个文件含同一订单时,在各导出 job 的日志中记录对应 artifact_id 与台账 id。
+
+接口若支持批量发送,固定请求键绑定整份实际请求体;同键重发内容不变。逐单成功的回执更新对应台账;只发失败子集时生成新请求键并保留与原批次关联。没有逐单回执时按对方整批接收语义记录,不凭本方猜部分成功。
+
+## 5. 详细设计:标志字段与取数条件
+
+### 5.1 状态来源
+
+| 业务标志 | 保存/计算位置 | 何时改变 |
+| --- | --- | --- |
+| 接收是否成功 | IF_REQ.RECEIVE_STATUS | 当前接收请求完成时 |
+| 作业是否登记 | IF_REQ.REGISTER_STATUS | 在 Oracle 幂等建立本次所需 job 后 |
+| 选品处理结果 | PROCURE job_node.output_json.result | 加购或正常跳过回报时:ADDED / SKIPPED |
+| 合同保存状态 | CONTRACT_CREATE 节点状态及 result | 开始、保存成功、失败或结果未知回报 |
+| 链路更新状态 | CONTRACT_CHAIN_UPDATE 节点状态及 result | 合同保存后调用订单中心,单独记录结果 |
+| 返利状态 | REBATE job.status / current_node_code | 等合同、执行、失败、提交成功 |
+| 平台订单接收状态 | JD_ORDER_PUSH.push_status | 每次发送和回执记录 |
+
+运维接口返回 `contract_save_status / chain_update_status / rebate_status` 三列,是以上权威记录的汇总字段,**不再写回接口表形成第二套状态**。当前节点按 job 指针及该节点最新 attempt 取值,历史成功节点保留,不用整个合同 job.status 代替保存状态。
+
+### 5.2 状态转换
+
+| job 状态 | 含义 | 后续动作 |
+| --- | --- | --- |
+| PENDING | 已登记,等待执行 | 对应脚本领取 |
+| RUNNING | 已被领取且正在执行 | 接收匹配 execution_token 的回报 |
+| SUCCESS | 本流程应做步骤完成 | 常规取数不再领取 |
+| FAILED_RETRYABLE | 明确失败,可重试当前步骤 | 到 next_check_at 后或运维重试,新增 attempt |
+| WAITING_EXTERNAL | 前置尚未完成或结果待核对 | 按 wait_reason 处理,不能一律自动重做 |
+| WAITING_USER / FAILED_MANUAL | 业务或运维需介入 | 在原待办上处理、核对并记录动作 |
+| CANCELLED | 明确停止办理 | 保留原数据和历史 |
+
+wait_reason 建议保存在 job 新增 VARCHAR(32) 字段:WAIT_CONTRACT、PROTOCOL_NOT_READY、RESULT_UNKNOWN。前两种下次检查条件;RESULT_UNKNOWN 需要核对外部结果,确认失败才可重试。
+
+节点复用 PENDING/RUNNING/SUCCESS/FAILED,未知结果用节点 `WAITING_EXTERNAL` 并在 output_json 记 `result=UNKNOWN`;需要新增相应前后端状态支持。未尽调转人工保留待办,不能把“停止自动办理”显示为“合同已保存”。
+
+```mermaid
+stateDiagram-v2
+ [*] --> PENDING
+ PENDING --> RUNNING: 领取
+ RUNNING --> SUCCESS: 完成
+ RUNNING --> FAILED_RETRYABLE: 明确可重试失败
+ RUNNING --> WAITING_EXTERNAL: 前置等待或结果未知
+ RUNNING --> WAITING_USER: 业务处理
+ FAILED_RETRYABLE --> PENDING: 重试当前步骤
+ WAITING_EXTERNAL --> PENDING: 前置满足或核对未成功
+ WAITING_EXTERNAL --> SUCCESS: 核对本流程已完成
+ WAITING_USER --> PENDING: 处理后恢复未完成步骤
+ PENDING --> CANCELLED: 停止办理
+```
+
+上图为通用 job 状态。合同保存结果核对成功时若链路尚未更新,实际转移到链路节点 PENDING,不能直接标整个合同 SUCCESS。
+
+### 5.3 领取规则
+
+| flow | 读取范围及条件 | 成功/失败记录 |
+| --- | --- | --- |
+| PROCURE | SALES 数据 READY;行 job=PENDING,或到期可重试 | 合格 ADDED、不合格 SKIPPED 均结束行 job;错误单独失败 |
+| ORDER_PUSH | 对应平台导出任务及历史未完成台账;ACCEPTED 不重发 | 归档文件及每单 push_status |
+| CONTRACT | ORDER_COMMON 数据 READY;合同 job 待执行,按 current_node 恢复 | CREATE 成功保存合同号;CHAIN 失败只重试 CHAIN |
+| REBATE | 同 INPUT_ID/版本的 CREATE 节点确认 SUCCESS,合同号非空;返利待执行或到期重试 | 从共用接口表读取返利字段;读取合同 job 合同号;提交后 SUCCESS |
+
+新录入与重试均覆盖历史未完成记录,不只筛选当天。创建返利 job 时可处于 WAIT_CONTRACT,合同失败不改它的接口输入;合同成功后,返利下一次扫描将其转 PENDING。
+
+领取使用 Oracle 短事务条件更新或行锁,同时检查状态并写 execution_token;不能先查询再无条件改状态。脚本中断保留 RUNNING,运维确认原进程已停止后释放并恢复;本期不以超时自动启动第二个执行器。
+
+### 5.4 节点定义与订单导出生命周期
+
+| flow | 节点链 | 正常结束条件 |
+| --- | --- | --- |
+| PROCURE | PROCURE_ITEM_RUN | 本行 ADDED 或 SKIPPED |
+| ORDER_PUSH | ORDER_EXPORT_FILE → ORDER_CENTER_PUSH | 文件完整归档后,解析及推送该批应发送订单,均确认接收 |
+| CONTRACT | CONTRACT_CREATE → CONTRACT_CHAIN_UPDATE | 保存及链路更新分别成功;未尽调为当前节点人工待办,不另建供数流程 |
+| REBATE | REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT | 正常提交成功;首次领取前检查合同保存条件 |
+
+ORDER_PUSH 脚本先领取历史未完成批次;再按当前排期需要产生 export_batch_id 并登记新 job(input_id=export_batch_id,input_version=1,source_line_id=空串)。新 job 从 ORDER_EXPORT_FILE 领取执行。导出失败保留该 job;已保存文件和路径写 job_artifact,node_code=ORDER_EXPORT_FILE,恢复时先查已有归档文件。
+
+归档成功后推进 ORDER_CENTER_PUSH:解析文件、按源订单建立/复用 JD_ORDER_PUSH、保存固定请求并推送。本批所有源订单到台账 ID 的关联写入该 job 的节点 output_json(大批量可用 artifact 保存清单),因此同一台账复用于多个文件时仍可判断每个批次是否完成。首次 job_id/artifact_id 不覆盖,后续文件关联由各批次记录持有。
+
+源订单均为 ACCEPTED,或本轮确无可推送订单时,批次 job SUCCESS;零数据结果记 NO_DATA 并显示零条,不创建空订单。部分失败保留当前推送节点,已接收项跳过;同订单不同内容记冲突,阻止该批盲目完成,交运维核对。脚本退出与该批次 job 成功是两件事。
+
+## 6. 详细设计:接口与交互时序
+
+### 6.1 接口清单
+
+| 接口/方向 | 状态 | 主要契约 |
+| --- | --- | --- |
+| `POST /api/jieda/inbound/sales` | 本方拟新增 | 完整销售对象,接收校验后落表 |
+| `POST /api/jieda/inbound/order-data` | 本方拟新增 | **一次传入合同及返利共同需要的数据**,返回接收记录 ID 和 INPUT_ID |
+| `POST /api/flows/{flow_code}/jobs/claim` | 本方拟新增 | 指定本轮 run_id,补登记后领取;返回 job、节点、attempt、执行令牌及输入关联 |
+| `POST /api/flows/ORDER_PUSH/jobs` | 本方拟新增 | 导出前按 export_batch_id 幂等登记平台/账号/范围及 job,导出失败也有作业可查 |
+| `GET /api/jobs/{job_id}/input` | 本方拟新增 | 对应接口头明细;返利额外返回关联合同 job 及合同号 |
+| `POST /api/jobs/{job_id}/nodes/{node}/{action}` | 复用 start/complete/fail 并扩展 | 结果、编号、错误、证据、attempt 和执行令牌;未知结果单独表达 |
+| `POST /api/jobs/{job_id}/push-records` | 本方拟新增 | 按源订单登记固定请求、逐单结果;校验所属 ORDER_PUSH 作业 |
+| `POST /api/jobs/{job_id}/retry` | 复用并修正 | 只恢复当前失败步骤,记录操作者及原因 |
+| 平台订单接收 API(本方→订单中心) | 外部契约待提供 | 来源订单、商品数量金额及实际接收回执 |
+| 链路节点更新 API(本方→订单中心) | 外部契约待提供 | 来源链路关联、对应节点、已保存合同标识及业务结果 |
+
+订单推送由 ORDER_PUSH 脚本、链路更新由 CONTRACT 脚本通过统一客户端调用。调用前通过状态中心记录固定请求,调用后保存回执。状态中心不同时后台发送同一请求。外部正式路径、业务枚举和鉴权由订单中心提供。
+
+### 6.2 请求、校验及重复处理
+
+接收报文在本方适配层统一表示为 `message_id + objects[]`。每个对象有 source_doc_id、input_version(可选)、header、lines。ORDER_COMMON 的 header/lines 包含第 4.5、7 章共同字段,不是分别推 contract 和 rebate。
+
+| 返回情况 | 本方建议 HTTP | 业务返回及持久化 |
+| --- | --- | --- |
+| 缺字段/格式不合法/空明细 | 422 | REJECTED,errors 含对象标识、行标识及字段路径;无业务头明细 |
+| 全部校验通过且保存成功 | 200 | ACCEPTED、request_log_id、input_ids;表示接收成功 |
+| 已接收同消息同内容 | 200 | DUPLICATE,返回原 input_ids,另记接收尝试 |
+| 已接收同标识不同内容 | 409 | CONFLICT,旧成功输入及作业不变 |
+| 保存失败 | 503 | STORE_FAILED+trace_id;不返回接收成功 |
+| 鉴权失败 | 401/403 | 拒绝,不建立业务作业 |
+
+首版整次请求校验通过后,同一个 Oracle 事务保存成功接收记录及全部对象头明细。任一必填缺失则整次失败,返回全部可识别字段错误;上游可按完整单据分批发送。缺的是返利字段也当次返回,因为这一份数据承担两个流程。
+
+客户类型、三个合同日期、合同明细字段,以及供应商/返利单位/计划员/订单号/货源细单号/含税协议单价在本次共用数据中按第 7 章校验。合同号是本方执行结果,接收时允许不存在。固定值、系统生成字段、页面尽调回显不要求上游提供。
+
+相同业务对象换 message_id 再传,由头表来源+版本唯一键识别:内容相同引用原 INPUT_ID,内容不同冲突。没有源版本号时,哈希只判断内容同异,不自动判定“哪条更新”。已成功接收的同 SOURCE_DOC_ID 改内容或改版本,本期返回 INPUT_CONFLICT,不自动创建第二组合同/返利 job;这是按业务标识检查,不能仅依赖版本唯一索引。运维和订单中心确认改单处理后再另行安排,原作业和结果保持可查。此前校验失败的修正重传可正常接收。
+
+接收失败日志存 IF_REQ;Oracle 本身不可用时只能写应用错误日志和 trace_id,不能宣称数据库必有失败记录。响应在事务提交后产生;若响应丢失,上游重传可取得原接收结果。
+
+### 6.3 时序图:一次接收,两流程共用
+
+**接收及合同处理。** 请求内校验并落表;合同排期触发后读取这份数据。
+
+```mermaid
+sequenceDiagram
+ participant OC as 订单中心
+ participant API as 状态中心
+ participant IF as 共用接口表
+ participant C as 合同脚本
+ participant CT as 合同系统
+ OC->>API: 一次推送合同返利共用数据
+ API->>API: 校验两流程上游必填
+ alt 校验失败
+ API->>IF: 记REJECTED,不建业务头明细
+ API-->>OC: 失败及字段位置
+ else 校验通过
+ API->>IF: 同事务保存接收记录和头明细
+ IF-->>API: 提交成功 INPUT_ID
+ API-->>OC: 接收成功 INPUT_ID
+ C->>API: 按合同排期领取
+ API->>API: 幂等登记同输入的合同/返利job
+ API->>IF: 按INPUT_ID读取输入并记登记完成
+ API-->>C: 合同job及共用数据
+ C->>CT: 录入并保存
+ CT-->>C: 保存成功及合同号
+ C->>API: 回报保存结果,推进至链路节点
+ C->>OC: 更新对应链路节点
+ OC-->>C: 更新回执
+ C->>API: 保存链路结果
+ end
+```
+
+**返利在另一排期继续。** 返利不再向订单中心获取另一份数据,按相同 INPUT_ID 读取接口表,并取得本方关联合同结果。
+
+```mermaid
+sequenceDiagram
+ participant R as 返利脚本
+ participant API as 状态中心
+ participant DB as 业务记录
+ participant IF as 同一共用接口表
+ participant RB as 返利中心
+ R->>API: 按返利排期领取
+ API->>DB: 检查同INPUT_ID的合同保存结果
+ alt 合同未保存
+ API->>DB: 返利保持WAIT_CONTRACT
+ API-->>R: 本条暂不领取,继续其他数据
+ else 合同保存成功且合同号存在
+ API->>IF: 读取相同INPUT_ID和版本
+ IF-->>API: 原头明细中的返利字段
+ API-->>R: 返利数据加本方合同号
+ R->>RB: 查协议、填总单、导明细并提交
+ RB-->>R: 提交结果
+ R->>API: 回报返利结果
+ API->>DB: 保存节点、编号和操作日志
+ end
+```
+
+两图通过同一 INPUT_ID 衔接,不要求两脚本连续运行。返利先启动时可先完成作业登记,但保持 WAIT_CONTRACT。
+
+### 6.4 时序图:平台订单归档推送
+
+```mermaid
+sequenceDiagram
+ participant R as 订单脚本
+ participant P as 平台
+ participant F as 共享文件夹
+ participant API as 状态中心
+ participant OC as 订单中心
+ R->>P: 按约定范围导出订单
+ P-->>R: 完整文件
+ R->>F: 保存到对应平台目录
+ alt 保存失败
+ R->>API: 回报文件错误,保留失败步骤
+ else 保存成功
+ R->>API: 登记文件、源订单及固定请求
+ API-->>R: 未接收订单和请求键
+ R->>OC: 按正式格式推送
+ alt 接收确认
+ OC-->>R: 业务接收回执
+ R->>API: 更新逐订单台账,记录成功/拒绝项
+ else 超时或连接中断
+ R->>API: 记录UNKNOWN及实际请求
+ end
+ end
+```
+
+共享目录保存后再组装推送。文件名冲突保留新批次路径,不覆盖原件;导出失败恢复导出,已归档的文件恢复时直接使用原件。
+
+### 6.5 时序图:合同保存成功、链路更新失败
+
+```mermaid
+sequenceDiagram
+ participant C as 合同脚本
+ participant CT as 合同系统
+ participant API as 状态中心
+ participant DB as Oracle
+ participant OC as 订单中心
+ C->>CT: 保存合同
+ CT-->>C: 成功 HT001
+ C->>API: CREATE complete,合同号和证据
+ API->>DB: 保存CREATE成功,当前节点改CHAIN_UPDATE
+ C->>API: 记录链路固定请求
+ C->>OC: 链路更新
+ OC-->>C: 明确失败或无响应
+ C->>API: 记录FAILED或UNKNOWN及原因
+ API->>DB: 保留合同号和失败位置
+ Note over C,DB: 脚本继续下一单;运维可见保存成功、链路异常
+ C->>API: 下次仅领取当前链路节点
+ Note over C,OC: UNKNOWN先核对;明确可重试才再次调用
+ C->>OC: 使用约定幂等请求更新
+ OC-->>C: 更新成功
+ C->>API: CHAIN complete及回执
+ API->>DB: 完成合同job;原CREATE记录不变
+```
+
+## 7. 详细设计:业务字段映射
+
+本章保留 Excel 的实际字段和行号。合同/返利列来源都指向第 4.5 节同一份接口输入;按各自模板生成执行数据。生成的模板、默认日期及映射版本记录到节点 output_json/文件证据,不再复制成独立合同或返利输入表。
+
+### 7.1 合同头
+
+| 目标字段 | 取值规则 | 行号 |
+| --- | --- | --- |
+| 客户名称 | 订单中心 | 12 |
+| 客户编码 | 目标系统自动生成 | 11 |
+| 尽调情况 | 读取回显;未尽调停止本单自动录入并转采购员处理 | 13 |
+| 结算类型 | 收支双向 | 15 |
+| 合同版本类型 | 预付:我司定制;账期:非格式合同 | 17 |
+| 合同金额(单位元) | 预估本次合作金额 | 24 |
+| 金额(元) | 明细汇总;与导入后页面汇总核对并回填 | 25 |
+| 合同签订日期、合同开始日期、合同结束日期 | 订单中心分别提供,三个独立字段 | 26–28 |
+| 是否重大合同 | 否 | 29 |
+| 是否预付款 | 预付:是;账期:否 | 30 |
+| 付款方式 | 电汇 | 32 |
+| 付款期限 | 预付:1;账期:订单中心账期天数,缺失不默认 30 | 33 |
+| 合同具体分类 | 国内药品类、器械类品种 | 37 |
+| 运输方式 | 公路运输 | 39 |
+
+### 7.2 合同明细
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 货品编码 | 订单中心 | 43 |
+| 货品名称 | 订单中心标准货品名称 | 44 |
+| 注册证号 | 订单中心 | 46 |
+| 规格、单位 | 按订单中心标准字段填报 | 47–48 |
+| 数量、单价 | 按订单中心最终采购数量、单价填报 | 49–50 |
+| 付款方式 | 电汇 | 52 |
+| 付款条件 | 订单中心;模板无此字段时不填 | 53 |
+| 付款期限 | 同合同头规则 | 54 |
+
+### 7.3 返利总单
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 返利合约 | 否 | 5 |
+| 供应商编码 | 订单中心 | 6 |
+| 返利单位编码 | 订单中心 | 7 |
+| 计划员 | 订单中心,按业务给定的计划员/采购员对应关系填写 | 8 |
+| 采购结算方式 | 文案“否”;与细单代码 3 分别映射 | 9 |
+| 承付模式 | 下月 N 号结 | 10 |
+| 协议开始日期、协议结束日期 | 当天 | 11–12 |
+
+### 7.4 返利细单
+
+| 目标字段 | 来源/规则 | 行号 |
+| --- | --- | --- |
+| 货品编码、数量、含税协议单价 | 本次共用接口数据的货品编码、数量及含税协议单价 | 15–17 |
+| 税率、关税税率 | 均为 0.13 | 18–19 |
+| 承付模式、承付参数 | 11、25 | 20–21 |
+| 细单开始日期 | 当天 | 22 |
+| 细单结束日期 | 本年度最后一天 | 23 |
+| 采购结算方式 | 3 | 24 |
+| 特定补偿预估值、进货补偿预估值 | 均为 0 | 25–26 |
+| 细单备注 | 订单中心订单号 | 33 |
+| 货源细单ID | 订单中心货源细单号;与订单号分列 | 34 |
+
+合同明细导入列按 Excel 的 Sheet3。表中标注无需填写的字段不填;系统自动生成字段由系统处理。
+
+
+### 7.5 映射与运行日期规则
+
+- 合同与返利的数量来自同一次报文;实际只提供一列数量时共用该列。含税协议单价与采购单价分别保留来源映射。
+- 首次执行该流程时生成所用默认日期及模板记录。重试已生成的相同步骤使用原输入,避免跨日重试更改已导入单据;新返利作业按返利首次执行当天生成“当天/年底”。这是实现规则,不增加业务确认步骤。
+- 合同客户编码为系统生成字段。现有 RPA 入口要求的 customerCode 实际用作客商搜索标识,须与系统自动生成的客户编码分清;按新页面取数方式适配,不要求上游为自动生成字段填占位符。
+- 映射表标注无需填写的字段不填。尽调读取页面回显;未尽调按原流程转采购员并通知。接口数据保留,人工完成后核对实际合同号并登记保存成功,进入链路更新步骤;返利按第 5.3 节条件领取。
+
+## 8. 详细设计:操作记录与一致性
+
+### 8.1 最少需要记录的操作信息
+
+| 场景 | 保存位置 | 必须能查到的信息 |
+| --- | --- | --- |
+| 每次接口接收 | IF_REQ | 请求时间、来源消息、INPUT_ID 关联、原报文、返回结果、字段错误、trace_id |
+| 脚本执行一步 | job_node | flow、job、INPUT_ID/版本、节点、attempt、run_id、执行机、开始结束、结果、错误 |
+| 平台订单发送一次 | JD_ORDER_PUSH+job_event | 原文件、源订单、固定请求键、实际请求、HTTP/业务返回、耗时、成功/拒绝范围 |
+| 合同保存 | job_node+job_artifact | 实际合同标识、保存确认、目标页面证据、所用输入和模板 |
+| 链路调用一次 | job_event+链路 job_node | 链路关联、节点代码、合同号、请求键、报文、回执、错误 |
+| 运维操作一次 | job_event / user_task | 操作人、时间、动作、原因、前后状态、证据和操作结果 |
+| 文件 | job_artifact | 原始订单、导入模板、截图或日志文件路径,文件类型及哈希 |
+
+job_event 建议事件类型包括 INPUT_BOUND、NODE_STARTED、NODE_SUCCESS、NODE_FAILED、API_REQUEST、API_RESPONSE、RETRY_REQUESTED、RESULT_VERIFIED、MANUAL_HANDOFF、JOB_FINISHED。detail_json 保存结构化信息。节点列表用 job_node.error_code/error_summary 筛选及展示;事件详情从 detail_json.errorCode/errorMessage 读取错误,不假定 job_event 存在独立错误列。详细错误与报文在详情展开。
+
+接收失败没有 job 时由 IF_REQ 独立展示;所有能定位到作业的动作都写 job_event。脚本运行 ID 使用现有 executor_run_id;无数据或启动失败的本轮摘要写应用日志,通过 run_id 查询,暂不新增运行历史表。
+
+### 8.2 事务及恢复顺序
+
+| 写入过程 | 事务范围 | 恢复办法 |
+| --- | --- | --- |
+| 接收成功 | Oracle:成功请求记录+所有接口头明细 | 事务失败整体返回失败;响应丢失可同消息重传 |
+| 登记作业 | Oracle:同输入的合同和返利 job 一起幂等建立 | 成功后回写 IF_REQ.REGISTER_STATUS;回写失败下次重复登记返回同 job,再补 DONE |
+| 节点成功 | Oracle:该 attempt 成功+业务编号+操作日志/证据索引+推进当前节点 | 回报丢失重发同完成键;同成功结果返回原状态,不再次推进 |
+| 外部接口调用 | 先 Oracle 保存请求,事务提交后调用,返回后独立事务记结果 | 超时保留 UNKNOWN,不把网络调用放在数据库事务中 |
+| 人工恢复 | Oracle:核查结果、状态变更和操作日志 | 同一操作请求 ID 重复提交返回原结果 |
+
+Oracle 接口输入和 Oracle 执行结果不要求同库事务,也不把执行状态反复写回 Oracle。接口表登记标志失败最多导致再次尝试登记,组合唯一约束保证不多建合同/返利作业。运维总览查两库时若 Oracle 不可用,仍可展示 Oracle 已保存的执行结果,并标记“接口原文暂不可查”,不能误显示数据不存在。
+
+### 8.3 文件和配置
+
+1 药城与药帮忙分别配置共享根目录和字段映射;现场路径由配置提供。下载完成后发布文件,文件名按批次区分,保留原件。后台返回文件列表,业务/运维通过现有证据入口打开或下载,不要求共享盘在所有机器使用同一盘符。
+
+订单中心连接、凭据、超时、链路节点映射、四条排期、重试间隔及模板配置集中管理。真实环境缺必要配置返回明确错误,不自动使用 fixture。日期、数量和金额按第 4、7 章数据类型保存,日志不保存 token 或数据库密码。
+
+## 9. 详细设计:正常路径与异常处理
+
+### 9.1 正常路径示例
+
+本例标识仅用于说明字段关系,不代表正式接口报文。
+
+| 时间/动作 | 接口记录 | 合同保存 | 链路更新 | 返利 | 记录位置 |
+| --- | --- | --- | --- | --- | --- |
+| 接收 M001 | ACCEPTED,INPUT_ID=I001,REGISTER=PENDING | 未登记 | 未登记 | 未登记 | IF_REQ+ORDER_H/L |
+| 任一相关脚本启动登记 | REGISTER=DONE | PENDING | 未开始 | WAIT_CONTRACT | 两个 job 都绑定 I001/1 |
+| 合同脚本处理 | 原输入不变 | RUNNING | 未开始 | WAIT_CONTRACT | CREATE 第 1 次尝试 |
+| 合同保存成功 HT001 | 原输入不变 | SUCCESS | PENDING | 可领取 | 合同号及保存证据 |
+| 更新链路成功 | 原输入不变 | SUCCESS | SUCCESS | 可领取 | 链路请求、回执;合同 job 完成 |
+| 返利下次运行 | 原输入不变 | SUCCESS | SUCCESS | RUNNING | 读取 I001 同一头明细+HT001 |
+| 返利提交完成 FL001 | 原输入不变 | SUCCESS | SUCCESS | SUCCESS | 返利编号、模板及提交结果 |
+
+### 9.2 异常矩阵
+
+| 异常代码(建议) | 识别条件 | 状态和保留内容 | 处理及恢复位置 |
+| --- | --- | --- | --- |
+| INPUT_REQUIRED | 本次共用数据缺合同或返利上游必填 | IF_REQ=REJECTED,字段路径/行号,无业务输入 | 上游补齐后重传;RPA 无需处理 |
+| INPUT_CONFLICT | 成功来源/版本再传不同内容 | CONFLICT,关联旧请求、保存新原文 | 核对来源及版本;不覆盖已使用数据 |
+| STORE_FAILED | 接口表事务失败 | 返回 503;应用日志 trace_id | 修复数据库后重传 |
+| JOB_REGISTER_FAILED | Oracle 已接收,Oracle 登记失败 | REGISTER=FAILED、原因;输入 READY | 恢复 Oracle,下一次脚本取数补登记 |
+| PLATFORM_QUERY_FAILED | 页面打不开或查询报错 | 行 job 明确失败,截图/错误 | 修复后重试该行;不记 SKIPPED |
+| NO_ELIGIBLE_PRODUCT | 查询成功但不满足采购条件 | job SUCCESS,result=SKIPPED、原因 | 正常结束,常规轮询不重复处理 |
+| FILE_SAVE_FAILED | 共享目录不可写/文件未完整 | ORDER_PUSH 失败、已知路径 | 修复目录后从导出/归档失败处继续 |
+| OC_ORDER_REJECTED | 订单中心明确业务拒收 | push=REJECTED,拒绝原因及范围 | 修正接口映射/源数据后处理失败单,成功单保留 |
+| EXTERNAL_RESULT_UNKNOWN | 加购、保存、推送或提交后无确定结果 | WAITING_EXTERNAL/UNKNOWN,请求或页面证据 | 先核对目标结果;确认未成功才重试 |
+| CONTRACT_DUE_DILIGENCE | 合同页面显示未尽调 | WAITING_USER、采购员待办和页面记录 | 按原业务人工处理,核验保存结果后继续链路 |
+| CHAIN_UPDATE_FAILED | 合同已保存,链路明确失败 | CREATE 成功;CHAIN 失败、回执 | 下次仅重试 CHAIN;合同号不清空 |
+| PROTOCOL_NOT_READY | 已有合同号,但返利协议不可查/暂不可操作 | 返利 WAITING_EXTERNAL,原因及检查时间 | 到期再查协议;不重建合同,不等第二次供数 |
+| REBATE_SUBMIT_FAILED | 返利系统明确提交失败 | 失败步骤、目标协议/总单、已导入信息 | 按原业务交人工处理;保留前面成功步骤 |
+| EXECUTOR_INTERRUPTED | 作业仍 RUNNING,进程已退出 | 执行机/run_id、最近节点、证据 | 运维确认无运行进程并核对结果后,恢复当前步骤 |
+
+所有正常失败恢复保留原 INPUT_ID、job 和历史 attempt。通用“重试”只增加当前失败节点尝试,不把 job 从第一个节点重新执行。已完成外部写入不能仅凭“失败”按钮反向当作没有发生。
+
+## 10. 运维设计:如何查看与处理
+
+### 10.1 查询入口及页面内容
+
+复用现有列表、节点详情、时间线、待办和证据组件,新增接收列表和“同一输入的合同/返利总览”,不用另建运维平台。
+
+| 页面 | 筛选条件 | 列表/详情内容 |
+| --- | --- | --- |
+| 接口接收列表 | 时间、来源单号、MESSAGE_ID、TRACE_ID、接收状态、登记状态 | 请求/返回原文、缺字段路径、INPUT_ID、重复来源、登记失败原因 |
+| 合同返利总览 | INPUT_ID、来源单号、合同号、返利号、三个业务状态、时间 | 同一行显示合同保存、链路更新、返利状态及最近错误;点击进入各 job |
+| 作业详情 | flow、job_id、状态、执行机、run_id | 来源输入、当前节点、每次尝试、耗时、错误、回报、外部编号 |
+| 订单推送列表 | 平台/账号、订单号、导出批次、push_status | 原文件、发送内容、回执、次数、结果未知/拒收原因 |
+| 操作和证据 | job/INPUT_ID、操作者、动作时间 | 谁点击重试/人工处理,前后状态、原因、截图、模板及日志 |
+
+“合同返利总览”由后端以 Oracle INPUT_ID 为基底,合并两份 Oracle job/节点结果;不存在 job 时显示未登记。分页/筛选由后端实现,不要求浏览器一次拉全表。只有接口原文不可查时保留错误提示,已存在的执行结果仍显示。
+
+### 10.2 典型查看路径
+
+| 运维问题 | 如何查 | 结论/下一步 |
+| --- | --- | --- |
+| 订单中心说推过,RPA 没处理 | 先查 MESSAGE_ID/来源单号的接收记录 | REJECTED 看缺字段;ACCEPTED+登记失败看数据库错误;DONE 看对应 job |
+| 合同存在,为什么流程显示失败 | 总览看合同保存和链路更新两列 | 保存成功/链路失败只补链路调用,不重建 |
+| 返利为什么一直没开始 | 看同 INPUT_ID 的合同保存结果及返利 wait_reason | 合同未保存则正常等待;保存成功但协议不可查则按目标状态处理 |
+| 同一批有的成功有的失败 | 订单推送按源订单筛选,选品按行 job 查看 | 重试失败项;已接收/已加购项不重复处理 |
+| 数据被改过或重复推过 | 接收尝试记录+DATA_HASH+操作日志 | 明确首次输入、重复/冲突报文及处理人 |
+
+### 10.3 运维动作及权限
+
+| 动作 | 允许条件 | 实际改变 | 必须留痕 |
+| --- | --- | --- | --- |
+| 重试当前步骤 | FAILED_RETRYABLE,且外部结果已明确 | 当前节点新增 attempt,job 转 PENDING | 操作人、原因、旧状态、新 attempt |
+| 重新登记作业 | 已 ACCEPTED 且 REGISTER≠DONE | 幂等补建缺失 job,完成登记标志 | 接收记录、结果、操作者 |
+| 核对结果:已成功 | UNKNOWN 或人工完成,提供实际业务编号/回执证据 | 确认对应节点成功并推进;合同保存后仍执行链路 | 目标编号、证据、核对人和时间 |
+| 核对结果:未成功 | 确认目标未写入,原执行进程已停止 | 转可重试当前步骤 | 核对依据、操作者、原因 |
+| 转业务处理 | 业务错误或系统要求人工 | 创建/复用现有 user_task,保存责任人 | 原因、接收人、操作记录 |
+| 停止办理 | 有明确业务指令 | CANCELLED,保留已产生结果 | 原因及外部已发生操作,不自动撤销外部单据 |
+
+只读运维可看列表及证据;恢复动作限定 OPS,业务待办按采购员等现有角色处理。恢复入口必须核对实际操作者身份,不能只相信请求体里填写的 operator。沿用现有鉴权方案补动作权限,不另加业务审批流。
+
+不能用“统一确认并继续”替代所有核对动作,不能直接改接口原始字段或数据库状态。金额、货品等源数据错误由订单中心修正重传;已被使用的输入按冲突规则处理。
+
+### 10.4 查询 API 及日志定位
+
+| API(建议) | 用途 |
+| --- | --- |
+| `GET /api/jieda/receipts`、`/receipts/{request_log_id}` | 无 job 的失败接收也能查 |
+| `GET /api/jieda/orders`、`/orders/{input_id}` | 同一输入的合同/链路/返利总览及头明细 |
+| `GET /api/jieda/order-pushes` | 按源订单查推送及文件 |
+| 现有 `GET /api/jobs/{job_id}`、`/timeline`、`/evidence-package` | 节点、历史尝试和证据 |
+| `GET /api/jobs/{job_id}/events`(新增) | 操作日志和出站请求/返回记录 |
+| `POST /api/jieda/receipts/{id}/register`(新增) | 运维手动补登记,与脚本取数复用同一逻辑 |
+| `POST /api/jobs/{job_id}/verify-result`(新增) | 按第 10.3 节核对 UNKNOWN/人工结果,记录依据后恢复 |
+
+应用日志统一携带 `trace_id、input_id、job_id、flow_code、node_code、attempt_no、run_id` 中当前已知项。接收阶段没有 job_id 时不编造;执行阶段没有 HTTP trace 时以 job/run 定位。列表展示最近错误,详情保留全部历史,不只覆盖最后一次失败。
+
+索引建议:接口记录按时间/状态/来源,业务头按源单号,job 按 `(flow_code,status,next_check_at)` 和 `(input_id,input_version)`,节点按现有 `(job_id,node_code,attempt_no)`,事件按 `(job_id,created_at)`,推送按来源唯一键及 `(push_status,next_retry_at)`。
+
+## 11. 实施影响与验收
+
+### 11.1 现有代码到新设计的映射
+
+| 现有位置 | 已核对现状 | 实施改动 |
+| --- | --- | --- |
+| [main.py 建单](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:499) | 供应商+日期键,固定首节点,只复用未完成作业 | 新接收/登记/领取按 flow 和 INPUT_ID;已完成作业也防重 |
+| [schema.sql](/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql:11) | job 无 flow/input 关联;已有节点、日志及证据表 | 第 4 章 Oracle 接口表及推送台账;job 扩展字段/索引,supplier_name/order_date 允许空;job_event 错误保存在现有 detail_json |
+| [db.py](/Users/yuuuu/Documents/AutoRpa/state_center/app/db.py:22) | Oracle 默认 autocommit=True | 对登记、领取、结果及推进使用显式短事务 |
+| [main.py 完成回报](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:712) | 保存节点、编号和推进分步执行;节点定义全局固定 | 同事务回报,校验 execution_token/attempt;按 flow 定义完成规则 |
+| [main.py 重试](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:812) | 已保留 attempt 历史、重试当前节点 | 保留机制,支持共用输入关联及 UNKNOWN 核对 |
+| [main.py 人工提交](/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py:1346) | 通用确认可直接推进;RETRY_CURRENT_NODE 分支当前只记录事件 | 按第 10.3 节校验结果和动作,实现真实重试及核查恢复 |
+| [agent.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/agent.py:1) | 通用代理主要调用 mock;存在节点硬编码与默认 attempt=1 | 四条独立入口、真实 runner、领取结果中的 attempt/token |
+| [contract_create_node.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/contract_create_node.py:241) | 已校验空明细及正数量,仍要求旧 customerCode | 适配共用接口数据和客商定位;保留有效输入检查 |
+| [contract_flow_steps.py](/Users/yuuuu/Documents/AutoRpa/rpa_yisaiqi/contract_flow_steps.py:788) | 缺价补 1.00,同码首行及前 30 行定位 | 移除缺价补值;按来源明细/目标行定位并核对实际导入结果 |
+| [Web 节点详情](/Users/yuuuu/Documents/AutoRpa/state_center_web/src/components/JobNodeDetailList.tsx:1) | 已展示时间、尝试次数、错误及证据 | 复用组件,补三个独立状态、输入关联、接收列表及恢复动作 |
+
+当前 Oracle 配置主要服务货品主数据读取;新接口表写入连接和权限需现场配置。当前代码不等于已具备四条真实平台执行能力,平台查询/导出及返利页面模块需要核对或实现。
+
+### 11.2 实施顺序
+
+1. 按共同输入契约核对第 4、7 章字段及合同/返利范围;确认现场接口表复用方式。
+2. 完成接收校验、Oracle 同事务保存及接收查询,验证失败重传和重复消息。
+3. 增加 flow/input 关联、作业登记、独立状态、领取及结果事务,跑通“同一输入两个 job”。
+4. 接平台订单导出、共享文件、台账及推送回执。
+5. 接合同页面保存和链路更新;返利读取同 INPUT_ID 与合同号完成提交。
+6. 接逐条选品;补齐异常及运维动作,配置四条排期后联调。
+
+### 11.3 验收用例
+
+| 编号 | 场景 | 预期结果 |
+| --- | --- | --- |
+| AT-01 | 一次完整 ORDER_COMMON 推送 | 一份头明细;登记两个相同 INPUT_ID/版本的合同、返利 job |
+| AT-02 | 仅返利必填缺失 | 当次返回字段错误,无业务输入;补齐后可重传 |
+| AT-03 | 相同请求重发、相同对象换消息 ID 重发 | 返回原输入,不新增执行作业 |
+| AT-04 | 同已成功来源/版本传不同内容 | 冲突可查,旧数据和外部结果不被覆盖 |
+| AT-05 | Oracle 已提交,Oracle 登记失败 | 接收记录显示登记失败;后续幂等补登记,不要求上游再推 |
+| AT-06 | 返利脚本先于合同脚本启动 | WAIT_CONTRACT;无额外供数请求、无返利提交 |
+| AT-07 | 合同保存成功,链路更新失败 | 保存 SUCCESS、链路 FAILED;只恢复链路,返利可按保存条件领取 |
+| AT-08 | 合同完成后返利按另一排期启动 | 读取同一接口数据和本方合同号,字段来源可追溯 |
+| AT-09 | 平台订单重复导出到不同文件 | 原件保留,已接收同源订单不重复入单 |
+| AT-10 | 推送部分成功/超时 | 按实际回执记录逐单结果;UNKNOWN 不直接按失败重发 |
+| AT-11 | 选品合格/不合格/查询失败 | 分别 ADDED、SKIPPED、FAILED;本批结束,不等付款 |
+| AT-12 | 脚本中断和重复点击 | 不重复领取;已有结果可查,确认停止并核对后恢复当前步骤 |
+| AT-13 | 合同已保存但回报丢失 | 重发完成键或核对目标结果,不再次建合同 |
+| AT-14 | 未尽调或返利提交失败 | 保留输入及原业务待办,其他记录继续;人工结果核对可留痕 |
+| AT-15 | 46 个 Excel RPA 字段 | 共用接口来源、固定值及日期按映射填报,缺价不补 1.00 |
+| AT-16 | 运维按 MESSAGE_ID、INPUT_ID、合同号查询 | 能串起接收、两流程、尝试、文件、请求和人工动作 |
+| AT-17 | 未授权恢复、旧 token 回报 | 拒绝写入;旧成功结果不被覆盖 |
+| AT-18 | 新旧流程并存 | 旧在途单保留原节点;新流程不误入对码/OCR/M5 |
+
+文档检查与业务验收分开:当前只完成设计;以上需要在测试数据库、实际共享目录及外部测试系统执行,mock 成功不代表真实业务通过。
+
+## 12. 未决事项、风险及对其他模块的影响
+
+### 12.1 联调前需要确认
+
+| 项目 | 已明确 | 剩余信息 |
+| --- | --- | --- |
+| 合同返利供数 | 一次推送共用 | 正式头明细、业务范围标识、消息 ID/版本;一组数据是否有多合同/多返利范围 |
+| 业务字段 | Excel 来源及默认值 | 实际 JSON 列名;采购/返利数量是否同字段,价格口径及精度 |
+| 订单出站接口 | 本方归档后推送 | 接收 URL、字段、批量/逐单回执、幂等和查询能力 |
+| 链路更新 | 合同保存成功后更新对应节点 | 节点代码、链路关联、合同标识字段及成功返回码 |
+| 返利可操作性 | 用同一份输入加已保存合同号 | 目标协议生成时间、是否依赖审批或链路更新;未确认前不新增业务审批门槛 |
+| 平台和共享目录 | 两平台分别导出归档 | 真实账号、订单状态/范围、文件样例、共享路径及四条排期 |
+| 盈利 | 5% 以上盈利 | 计算基数及边界;不直接套用旧方案 0.95 公式 |
+| 现场库和模板 | 接口输入与处理记录分开 | 现有 Oracle 表、权限、正式模板和确有要求的附件 |
+
+### 12.2 可能问题与控制位置
+
+| 问题 | 影响 | 对应设计 |
+| --- | --- | --- |
+| 共用一个 processed 标志 | 合同处理后返利漏取 | 第 5 章分离合同保存、链路、返利标志 |
+| 两份接口输入被分别修改 | 合同/返利货品数量不一致 | 同 INPUT_ID/版本只保留一份原始头明细 |
+| 成功接收后没有 job | 数据落表但长期未执行 | REGISTER_STATUS 和取数前补登记;运维可查 |
+| 接口/页面结果未知被当失败 | 重复订单、合同或返利 | UNKNOWN 核对后恢复,不无条件重做 |
+| 把链路失败当合同未保存 | 再建一份合同 | 保存节点结果与链路节点独立 |
+| 当前合同缺价补值或明细定位错误 | 错金额、同码行覆盖或漏行 | 接收校验及第 11.1 节代码修正;单规格规则不消除多订单行的问题 |
+| 原人工确认直接推进 | 未核实外部结果就完成作业 | 第 10.3 节按动作和证据校验 |
+| 新旧流程改全局状态机 | 旧对码、OCR、M5 在途单无法继续 | flow/version 分开,旧单保持原链;切换前确定同一来源由哪套办理 |
+| 两库或共享目录不可用 | 接收失败、原文不可查、文件不可达 | 独立错误可见;执行记录保留,不伪装成空数据或成功 |
+
+本稿的表与字段是详细设计建议,尚未核对现场字典。对外契约尚未提供的部分保留为明确缺口;不以增加业务操作、复制数据或自动猜规则来填补。
diff --git a/docs/superpowers/specs/2026-09-13-jieda-rpa-overview-design.md b/docs/superpowers/specs/2026-09-13-jieda-rpa-overview-design.md
new file mode 100644
index 0000000..e08691c
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-13-jieda-rpa-overview-design.md
@@ -0,0 +1,120 @@
+# 福建洁达采购 RPA 概要设计
+
+| 文档属性 | 内容 |
+| --- | --- |
+| 版本 | V3.1 |
+| 日期 | 2026-09-13 |
+| 状态 | 设计评审稿;未实施代码或建表 |
+| 适用系统 | 当前 AutoRpa 状态中心、RPA 执行程序及福建洁达采购业务 |
+| 本版核心关系 | 订单中心一次推送合同/返利共用数据;合同先处理,返利按自己的排期读取同一份数据 |
+| 文档范围 | 概要设计、表与状态详细设计、接口及时序、异常恢复、运维查询、实施与验收 |
+| 替代关系 | 整合原 Codex 设计稿 V1.9 和技术实现说明 V2.3;旧方案仅供历史参考 |
+
+**阅读顺序:** 业务评审看第 1–3 章;开发看第 4–9 章;运维看第 10 章;实施验收及未决问题见第 11–12 章。
+
+
+> 详细的数据模型、接口字段、状态、时序、异常、运维和验收见[《福建洁达采购 RPA 详细设计》](./2026-09-13-jieda-rpa-detailed-design.md)。
+
+## 1. 设计依据与范围
+
+### 1.1 已确认业务规则
+
+| 编号 | 规则 |
+| --- | --- |
+| BR-01 | 四条脚本分别运行,自动时间和频率不同,也支持业务点击;一次 HTTP 接收不等待四条流程全部执行 |
+| BR-02 | 选品读取未处理销售明细,在 1 号药城和药帮忙均按同一套采购条件执行:按批准文号查询,依次校验自营仓、盈利、有效期、库存,再按仓库距离选择;合格加购物车,不合格跳过,处理完本批结束;不等待人工付款 |
+| BR-03 | 本业务相同批准文号只有一个规格,不增加规格、厂家、包装或单位换算匹配 |
+| BR-04 | 平台订单导出到各平台对应共享文件夹,按订单中心接口格式组装并推送;对码由订单中心内部处理 |
+| BR-05 | 订单中心一次传来的完整业务数据供合同、返利共用,不拆成两次独立供数,不重复保存两份相同头明细 |
+| BR-06 | 接收时检查上游应传的必填字段,缺项当次返回失败和字段位置;通过并落表后返回成功 |
+| BR-07 | 合同保存成功后调用订单中心链路接口更新对应节点;返利读取同一份接口数据,并使用本方已取得的合同号 |
+| BR-08 | 手工单暂不做;合同/返利字段、固定值及原业务人工分支按提供材料执行 |
+| BR-09 | RPA 将平台订单的对码原始信息随订单报文传给订单中心;订单中心返回或后续提供标准货品结果,本方不自行判断货品编码 |
+
+### 1.2 参考材料
+
+| 材料 | 用途 |
+| --- | --- |
+| [合同系统录入流程](/Users/yuuuu/Documents/AutoRpa/资料/合同系统录入流程.md) | 合单、尽调、提交、回写;页面步骤指向 rpa_yisaiqi |
+| [选品条件 20260902](/Users/yuuuu/Documents/AutoRpa/资料/采购平台选品条件优先项_20260902.txt) | 自营仓、盈利、效期、库存和仓库顺序 |
+| [合同返利字段映射 20260911](/Users/yuuuu/Documents/AutoRpa/资料/合同返利系统字段映射_20260911.xlsx) | 两个工作表的 46 个 RPA 字段及 Sheet3 导入列 |
+| [仓库一期技术设计](/Users/yuuuu/Documents/AutoRpa/资料/采购主链RPA一期设计文档.md) | 参考概要/详细设计、实体、接口、状态和审计的组织方式;不沿用其旧业务范围 |
+| [现有数据库结构](/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql) | 判断 job、节点、待办、日志及证据记录可复用部分 |
+
+本次会话的业务确认优先于旧文档。下文表名、内部代码、API 路径及列类型为本方建议,正式外部字段名、节点代码和现场 Oracle 结构仍需按第 12 章核对。
+
+## 2. 概要设计:架构与分工
+
+### 2.1 总体架构
+
+```mermaid
+flowchart TB
+ OC[订单中心] -->|销售数据 / 合同返利共用数据| API[FastAPI 接收校验]
+ API -->|校验成功同事务落表| IFDB[(Oracle 接口头明细及接收记录)]
+ S[四条独立排期 / 点击脚本] --> RPA[RPA 执行程序]
+ RPA <-->|领取数据 / 回报结果| SC[状态中心]
+ SC -->|读取同一份输入| IFDB
+ SC <--> BDB[(Oracle 作业、节点、调用记录)]
+ RPA -->|查品加购 / 导出订单| PL[采购平台]
+ PL --> F[对应共享文件夹]
+ F -->|组装报文并推送| OC
+ RPA -->|录入保存| CT[合同系统]
+ RPA -->|保存后调用链路接口| OC
+ RPA -->|同一接口数据加合同号| RB[返利中心]
+ UI[运维列表 / 详情 / 日志] --> SC
+```
+
+接口数据与业务处理记录分开:**Oracle 接口表回答“订单中心传了什么”,Oracle 业务记录回答“本方做到哪一步、结果是什么、谁操作过”。** RPA 通过状态中心读取接口数据,数据库连接集中在后端。
+
+### 2.2 接口表与业务表的选择
+
+| 方式 | 特点 | 本期选择 |
+| --- | --- | --- |
+| 仅一张接口表加一个 processed 标志 | 简单,但合同处理后可能被整体标记完成,返利漏取;也无法表达多次失败尝试 | 不采用 |
+| 接口表再完整复制成合同表、返利表 | 三份字段容易不一致,需要额外同步 | 不采用 |
+| 接口头明细保留一份输入,业务作业/节点保存独立状态与结果 | 合同、返利通过相同 INPUT_ID 关联;历史操作复用现有日志 | **采用** |
+
+现有 `job / job_node` 就是本期业务处理表,无需再复制合同、返利输入。为无 job 的接收失败补接收记录;为跨导出文件识别同一订单,补逐订单推送台账。这两项有明确查询及防重用途。
+
+### 2.3 模块职责及运行方式
+
+| 模块 | 职责 |
+| --- | --- |
+| FastAPI | 校验、保存接收数据、取数、原子领取、保存回报、查询及运维动作 |
+| Oracle 接口库 | 接收尝试、销售头明细、合同/返利共用头明细;成功输入保留原值 |
+| Oracle 状态中心 | 各流程 job、节点尝试、业务编号、订单推送状态、操作日志、证据索引 |
+| 四条 RPA 脚本 | 本批逐条执行;平台订单和合同链路接口由对应脚本通过适配器调用并回报 |
+| 现有 Web | 接收查询、共用数据处理总览、节点尝试、文件和运维动作 |
+
+四条脚本分别配置时间、频率及本轮条数上限,单执行机每条脚本设单实例启动控制。FastAPI 常驻。脚本启动时调用后端登记未建立的 job,再领取本流程数据;订单导出先生成并登记 export_batch_id,恢复失败批次沿用该 ID;本批结束释放本机运行锁。没有数据时记录本轮零条并退出,不新增第五条业务或后台维护流程。
+
+## 3. 概要设计:业务流程与完成边界
+
+| 流程 | 输入 | 正常处理 | 完成含义 |
+| --- | --- | --- | --- |
+| PROCURE | 销售接口表的一条明细 | 按批准文号查询,按采购条件加购或跳过 | 该行处理完;不代表付款成交 |
+| ORDER_PUSH | 对应平台导出的订单文件 | 保存共享目录、提取对码原始信息、组装、推送、记录回执 | 应推送订单确认接收;不等待内部对码 |
+| CONTRACT | 共用订单接口头明细 | 录入并保存合同,保存合同号,更新订单中心链路 | 保存及链路更新分别记录;链路失败不重建合同 |
+| REBATE | **同一共用订单接口头明细**+本方合同保存结果 | 按合同号查协议、填总单、导细单、提交 | 返利提交成功;不要求订单中心再次推数据 |
+
+### 3.1 合同与返利的关联
+
+```mermaid
+flowchart TD
+ I[一次推送:INPUT_ID = I001] --> C[合同 job:INPUT_ID = I001]
+ I --> R[返利 job:INPUT_ID = I001]
+ C --> CS[合同保存成功:合同号 HT001]
+ CS --> CH[合同链路更新]
+ CS -->|提供合同号| R
+ R --> RS[返利提交结果]
+```
+
+合同、返利是两个独立作业,绑定同一 INPUT_ID 和 INPUT_VERSION。合同保存号只从对应合同 job 取得,不能按同品种或同一天猜配。
+
+返利默认在“合同已确认保存成功且合同号存在”后可领取;链路回传结果另列显示,**不擅自把链路成功增加为返利前置条件**。若目标返利协议暂不可查,记录等待原因,下次返利运行再查询。是否必须审批通过由目标系统及接口方确认,不新增审批动作。
+
+### 3.2 选品规则
+
+1 号药城和药帮忙共用以下选品判断逻辑。平台只负责登录、搜索、读取商品信息和执行加购,不能改变判断优先级。自营仓、5% 以上盈利、有效期大于等于 12 个月、库存满足采购数量四项都通过后,按下列仓库顺序选择。不合格记跳过,平台技术错误记失败。
+
+广东壹号药业有限公司、昆山亿方药业有限公司、湖北亿昊药业有限公司、天津壹号药业有限公司、重庆亿昊药业有限公司、重庆壹号医药有限公司、湖南壹号药业股份有限公司、辽宁亿昊药业有限公司、河南壹号医药有限公司、甘肃壹号云医药有限责任公司企业店、陕西壹号药业有限公司。
diff --git a/docs/superpowers/specs/2026-09-14-jieda-procure-crawler-detail.md b/docs/superpowers/specs/2026-09-14-jieda-procure-crawler-detail.md
new file mode 100644
index 0000000..a3cba78
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-14-jieda-procure-crawler-detail.md
@@ -0,0 +1,498 @@
+# 洁达选品:改造商城查价工程(详细方案)
+
+> Windows 按本文改同事原工程。概要口径见 [2026-09-14-jieda-procure-crawler-overview.md](./2026-09-14-jieda-procure-crawler-overview.md)。
+> 不要改仓库一期状态机,不要把 ±10% 比价逻辑留在主流程里。
+
+| 文档属性 | 内容 |
+| --- | --- |
+| 版本 | V1.0 |
+| 日期 | 2026-09-14 |
+| 执行环境 | Windows + 艺赛旗 Python 应用(`isa-rpa` + `uv`) |
+| 基线代码 | 同事工程 `main.py`(约 907 行) |
+| 建议工作副本 | 拷一份到本仓库 `rpa_yisaiqi/procure_mall/`,或 Windows 本地独立目录;不要在原 zip 上直接当唯一真源 |
+
+---
+
+## 1. 原工程:留什么、删什么、补什么
+
+### 1.1 原样保留(不要重写)
+
+| 函数 / 能力 | 作用 |
+| --- | --- |
+| `SecretClient` + `YAOEX_SECRET_NAME` / `YBM_SECRET_NAME` | 密码箱登录 |
+| `get_yaoex_auth` / `ensure_yaoex_logged_in` | 1 号药城登录,抽 `token / userId / buyerCode` |
+| `get_ybm_cookies` / `ensure_ybm_logged_in` | 药帮忙登录,抽 cookie |
+| `build_yaoex_search_request` / `query_yaoex_products` | `homeSearchList`,`keyword=批准文号`,`per=20`,`deadLineStart=12` |
+| `decrypt_yaoex_price` / `build_yaoex_price_decrypt_key` | 价格 AES 解密 |
+| `build_ybm_search_payload` / `query_ybm_products` | `list-products`,`queryWord=批准文号`,`isThirdCompany=0`,`isEffectPeriodOverOneYear=1` |
+| `post_json_with_retry` | 3 次重试 |
+| 按批准文号缓存 | 同一文号不打两次 |
+
+搜索接口:
+
+- 1 号药城:`POST https://gateway-b2b.fangkuaiyi.com/home/search/homeSearchList`
+- 药帮忙:`POST https://www.ybm100.com/new-front/search/list-products`
+
+字段映射继续用原代码:
+
+| 结果字段 | 1 号药城 | 药帮忙 |
+| --- | --- | --- |
+| 平台索引ID | `spuCode` | `productInfo.skuId` |
+| 平台名称 | `shortName` | `productInfo.showName` |
+| 平台规格 | `spec` | `productInfo.spec` |
+| 平台价格 | `decrypt(price)` | `price`,空则 `fob`;库存 0 时价当 0 |
+| 效期 | `deadLine` | `productInfo.nearEffect` |
+| 商家 | `vendorName` | `productInfo.shopName` |
+| 库存 | `stockCount` | `productInfo.availableQty` |
+
+### 1.2 必须删除或停用
+
+- `is_in_purchase_price_band`
+- `filter_rows_by_purchase_price_band`
+- `main()` 里「价格区间过滤后总行数」那段
+- 把全部候选行当最终输出
+
+### 1.3 必须新增
+
+- 选品规则引擎(两平台共用)
+- 按行决策:每行每平台 0 或 1 个仓
+- 加购适配器(接口优先,页面兜底)
+- `--dry-run` / `--limit` / `--platform`
+- 新结果表(结论 + 原因,不再是比价明细大表)
+
+---
+
+## 2. 建议拆文件
+
+原 `main.py` 已 900 行,不要再往里堆加购和规则。Windows 按下列拆;若艺赛旗工程限制只能单文件,也必须把规则和加购写成独立函数,禁止写进 `query_*`。
+
+```text
+procure_mall/
+ pyproject.toml # 沿用原工程
+ main.py # 只编排:读入 → 登录 → 循环行 → 写结果
+ search_yaoex.py # 从 main.py 搬登录+查询+解密
+ search_ybm.py # 从 main.py 搬登录+查询
+ http_util.py # post_json_with_retry 等
+ rules.py # 合格判定 + 仓库排序
+ cart_yaoex.py # 1 号药城加购
+ cart_ybm.py # 药帮忙加购
+ io_excel.py # 读输入、写结果
+ tests/test_rules.py # 规则单测,不连商城
+```
+
+`main.py` 目标骨架:
+
+```python
+def main() -> None:
+ args = parse_args() # --dry-run 默认 True;--limit N;--platform all|yaoex|ybm
+ rows = load_input_rows(args.input_path)
+ if args.limit:
+ rows = rows[: args.limit]
+
+ yaoex_auth = asyncio.run(get_yaoex_auth()) if args.platform in ("all", "yaoex") else None
+ ybm_cookies = asyncio.run(get_ybm_cookies()) if args.platform in ("all", "ybm") else None
+
+ decisions = []
+ for purchase in rows:
+ if yaoex_auth is not None:
+ decisions.append(run_one_platform(purchase, "1号药城", yaoex_auth, dry_run=args.dry_run))
+ if ybm_cookies is not None:
+ decisions.append(run_one_platform(purchase, "药帮忙", ybm_cookies, dry_run=args.dry_run))
+
+ write_decision_file(decisions)
+```
+
+单行单平台:
+
+```python
+def run_one_platform(purchase, mall_name, auth, dry_run=True) -> dict:
+ try:
+ candidates = search_candidates(mall_name, auth, purchase["批准文号"])
+ except Exception as exc:
+ return decision(purchase, mall_name, status="FAILED", reason=f"QUERY_FAILED:{exc}")
+
+ picked, rejected = select_warehouse(candidates, purchase)
+ if picked is None:
+ return decision(purchase, mall_name, status="SKIPPED", reason=rejected_summary(rejected), raw=candidates)
+
+ if dry_run:
+ return decision(purchase, mall_name, status="ADDED", reason="DRY_RUN", picked=picked)
+
+ try:
+ cart_ref = add_to_cart(mall_name, auth, picked, qty=purchase["采购数量"])
+ except Exception as exc:
+ return decision(purchase, mall_name, status="FAILED", reason=f"CART_FAILED:{exc}", picked=picked)
+
+ return decision(purchase, mall_name, status="ADDED", reason="CART_OK", picked=picked, cart_ref=cart_ref)
+```
+
+查询失败不得改成 SKIPPED。
+
+---
+
+## 3. 输入 / 输出
+
+### 3.1 输入 Excel(改模板,不要沿用只有采购价格的比价模板)
+
+路径建议:`桌面/福建商城选品测试/选品输入.xlsx`
+第一行表头,从第 2 行起数据。
+
+| 列名 | 必填 | 说明 |
+| --- | --- | --- |
+| 行号 | 否 | 没有则按 Excel 行号 |
+| 批准文号 | 是 | 查询关键字;空行跳过 |
+| 销售价格 | 是 | 毛利基准。缺或无法转数字 → 该行两平台都 SKIPPED,原因 `NO_SALE_PRICE` |
+| 采购数量 | 是 | 库存门槛和加购数量。缺或 ≤0 → SKIPPED,原因 `NO_QTY` |
+| 通用名 | 否 | 回填 |
+| 规格 | 否 | 回填,不参与匹配 |
+| 生产厂家 | 否 | 回填,不参与匹配 |
+
+本期**不要用采购价格做合格判断**。模板里可以留着采购价格列,规则引擎忽略它。
+
+`--limit 5` 只跑前 5 条有效行。
+
+### 3.2 输出 Excel
+
+路径:`桌面/福建商城选品测试/结果/选品结果_YYYYMMDD_HHMMSS.xlsx`
+一行 = 一个输入行 × 一个平台。
+
+| 列名 | 说明 |
+| --- | --- |
+| 行号 | 输入行 |
+| 批准文号 | |
+| 通用名 | |
+| 销售价格 | |
+| 采购数量 | |
+| 商城 | `1号药城` / `药帮忙` |
+| 结论 | `ADDED` / `SKIPPED` / `FAILED` |
+| 原因 | 见 3.3 |
+| 选中商家 | ADDED 时必填 |
+| 平台索引ID | sku/spu |
+| 平台名称 | |
+| 平台规格 | |
+| 平台价格 | |
+| 效期原文 | |
+| 库存 | |
+| 毛利率 | `(销售价-平台价)/销售价`,保留 4 位小数 |
+| dry_run | `Y/N` |
+| 加购凭证 | 接口回执或页面摘要;dry-run 为空 |
+| 候选商家清单 | 可选,分号拼接,便于核对为什么没选上 |
+
+另写一份 `未匹配商家_时间戳.txt`:所有出现过、但不在 11 家名单里的商家名(去重)。用来决定要不要加别名。
+
+### 3.3 原因码
+
+| 原因码 | 结论 | 何时 |
+| --- | --- | --- |
+| `NO_SALE_PRICE` | SKIPPED | 销售价缺失 |
+| `NO_QTY` | SKIPPED | 采购数量缺失或 ≤0 |
+| `NO_HIT` | SKIPPED | 接口无货或空列表 |
+| `NO_SELF_WAREHOUSE` | SKIPPED | 有货但不在 11 家 |
+| `MARGIN_LT_5PCT` | SKIPPED | 自营仓有货但毛利不够(可与其它原因并列,取「没有任何候选过齐 1~4」) |
+| `EXPIRY_LT_12M` | SKIPPED | 效期不够 |
+| `STOCK_LT_QTY` | SKIPPED | 库存不够 |
+| `NO_CANDIDATE_AFTER_RULES` | SKIPPED | 1~4 筛完为空(推荐作为总原因,明细放候选清单) |
+| `DRY_RUN` | ADDED | 规则通过,未真正加购 |
+| `CART_OK` | ADDED | 加购成功 |
+| `QUERY_FAILED:...` | FAILED | 搜索接口失败(已重试) |
+| `CART_FAILED:...` | FAILED | 加购失败 |
+| `CART_UNKNOWN:...` | FAILED | 加购后无法确认是否进车 |
+
+SKIPPED 的总原因用 `NO_CANDIDATE_AFTER_RULES`,并在「候选商家清单」里带每条被淘汰原因。不要只写一个模糊的「不合格」。
+
+---
+
+## 4. 规则引擎(`rules.py`)
+
+两平台只把候选翻译成同一结构,再进引擎。平台代码禁止自己改优先级。
+
+```python
+@dataclass
+class Candidate:
+ mall_name: str
+ sku_id: str
+ name: str
+ spec: str
+ price: float
+ expiry_raw: str
+ expiry_months: float | None
+ vendor_raw: str
+ vendor_norm: str
+ stock: float
+ extra: dict
+
+@dataclass
+class PurchaseLine:
+ approval_no: str
+ sale_price: float
+ qty: float
+```
+
+### 4.1 商家规范化
+
+```text
+去首尾空格
+去掉前缀 "[自营]"(全角空格也去)
+压缩连续空格为单空格
+```
+
+规范化后必须 **等于** 下面 11 个字符串之一,顺序即优先级(下标越小越优先):
+
+```python
+WAREHOUSE_ORDER = [
+ "广东壹号药业有限公司",
+ "昆山亿方药业有限公司",
+ "湖北亿昊药业有限公司",
+ "天津壹号药业有限公司",
+ "重庆亿昊药业有限公司",
+ "重庆壹号医药有限公司",
+ "湖南壹号药业股份有限公司",
+ "辽宁亿昊药业有限公司",
+ "河南壹号医药有限公司",
+ "甘肃壹号云医药有限责任公司企业店",
+ "陕西壹号药业有限公司",
+]
+```
+
+本期不做别名、不做包含匹配。对不上的 `vendor_raw` 记入未匹配清单。
+
+### 4.2 价格
+
+```text
+毛利 = (销售价格 - 平台价) / 销售价格
+合格:毛利 >= 0.05
+即:平台价 <= 销售价格 * 0.95
+```
+
+平台价无法转数字 → 该候选淘汰。销售价 ≤ 0 → 整行 SKIPPED。
+**禁止**用采购价、禁止 ±10% 区间。
+
+### 4.3 效期
+
+目标:剩余 ≥ 12 个月。解析顺序:
+
+1. 纯数字(含小数):当作月数。
+2. 含「月」:抽出数字当月数。
+3. 日期(`YYYY-MM-DD` / `YYYY/MM/DD` / `YYYY-MM`):`(到期日 - 今天) / 30.4375`,到期日按该月最后一天。
+4. 都不是:`expiry_months = None`,该候选淘汰(原因 `EXPIRY_UNPARSEABLE`)。
+
+接口虽已带 12 个月过滤,仍以本函数为准。
+
+### 4.4 库存
+
+`stock >= qty`。库存无法转数字或为 0 → 淘汰。药帮忙无货把价格改成 0 的旧逻辑可保留在映射层,引擎侧会因库存/价格再淘汰一次。
+
+### 4.5 择仓
+
+```text
+passed = [c for c in candidates if 自营仓 and 毛利 and 效期 and 库存]
+if not passed: return None
+return min(passed, key=lambda c: WAREHOUSE_ORDER.index(c.vendor_norm))
+```
+
+同一仓库多条 SKU:取平台价更低的;再相同取库存更大的。不要两条都加购。
+
+### 4.6 必须先写的单测(不连网)
+
+文件:`tests/test_rules.py`。Windows 在改 `main.py` 前先把这些测过。
+
+| 用例 | 期望 |
+| --- | --- |
+| 11 家名单内、毛利 6%、效期 13 月、库存够 | 选出该仓 |
+| 毛利刚好 5% | 选出(`>= 0.05`) |
+| 毛利 4.9% | 淘汰 |
+| 商家不在名单 | 淘汰 |
+| 商家带 `[自营] 广东壹号药业有限公司` | 视为广东壹号 |
+| 广东和昆山都合格 | 选广东 |
+| 只有昆山合格 | 选昆山 |
+| 库存 9、采购 10 | 淘汰 |
+| 效期 11 个月 | 淘汰 |
+| 效期 `2027-09-14` 且今天起 ≥12 个月 | 过 |
+| 空候选 | SKIPPED 语义(函数返回 None) |
+| 销售价用 100、平台价 96 | 淘汰(毛利 4%) |
+| 销售价 100、平台价 95 | 过 |
+
+用采购价 ±10% 的旧例子(平台价 96、采购价 90)在新规则下若销售价是 100 应淘汰。加一条单测钉死,防止有人把 ±10% 加回来。
+
+命令:
+
+```bat
+cd /d 工程目录
+uv run pytest tests/test_rules.py -v
+```
+
+期望:全绿。未装 pytest 则 `uv add --dev pytest`。
+
+---
+
+## 5. 加购(原工程没有,这是新工作)
+
+### 5.1 抓接口(每个平台做一次,记录进 `docs/cart-api.md` 或工程内 `CART_API.md`)
+
+在 Windows 上用已经登录的账号:
+
+1. 打开对应商城,F12 → Network,过滤 XHR/Fetch。
+2. 搜索一个确定能加的自营仓商品。
+3. 点「加入购物车」,看新出现的请求。
+4. 记录:URL、方法、请求头(cookie / token 哪些必要)、body、成功响应长什么样。
+5. 把 body 里的 sku/spu、数量、商家 ID 标出来。
+
+1 号药城登录态用 `ycgltoken` 等;药帮忙用整份 cookie。加购请求必须带上搜品时同一套认证,不要重新登录。
+
+若加购接口有 CSRF / 签名 / 人机验证:不要硬解,改页面点击(5.3)。
+
+### 5.2 接口加购(优先)
+
+`cart_yaoex.add(auth, candidate, qty) -> cart_ref`
+`cart_ybm.add(cookies, candidate, qty) -> cart_ref`
+
+- 数量 = 采购数量。
+- 成功:响应明确成功,或随后查购物车能看到该 sku + 数量。
+- 响应含糊:结论 `FAILED`,原因 `CART_UNKNOWN`,不要报 ADDED。
+- 已在车里:视为成功(幂等),不要再加一次导致数量翻倍。先查车再加。
+
+### 5.3 页面加购(接口抓不到才用)
+
+只对规则已经选出的那 **1 条** 操作:
+
+1. 打开商品页或搜索结果中该 sku。
+2. 填数量。
+3. 点加入购物车。
+4. 等成功提示或打开购物车核对。
+
+禁止:重新用页面搜索批准文号再人工挑仓。仓已经由规则选定。
+
+页面选择器用艺赛旗 `BrowserSession`,登录态复用。不要为加购再登一次(除非会话丢了)。
+
+### 5.4 dry-run
+
+默认 `--dry-run`。`ADDED + DRY_RUN` 表示规则通过。关 dry-run 必须显式 `--execute-cart`。
+
+第一次真正加购只用 `--limit 1 --platform yaoex --execute-cart`,人工打开购物车核对后再加量。
+
+---
+
+## 6. Windows 环境与运行
+
+### 6.1 环境
+
+- 艺赛旗 Python 应用,能 `uv run main.py`
+- 密码箱已有:
+ - `yaoex_GYJDLHT_login`
+ - `yaobangmang_18146141930_login`
+- 账号必须是业务允许加购的测试/正式采购账号。真正加购前跟业务确认,避免测出一车真货。
+- 桌面目录:`福建商城选品测试\`
+
+### 6.2 命令
+
+```bat
+:: 只判定,不加购(默认)
+uv run main.py --dry-run --limit 5
+
+:: 只跑一个平台
+uv run main.py --dry-run --limit 5 --platform yaoex
+
+:: 确认规则后,真加购 1 条
+uv run main.py --execute-cart --limit 1 --platform yaoex
+```
+
+把命令写进 `README.md`,替换原「查价结果」说明。
+
+### 6.3 验收顺序(必须按这次序,不要一上来一千条真加购)
+
+1. `pytest tests/test_rules.py` 全绿。
+2. `--dry-run --limit 5 --platform yaoex`:结果表 5 行,结论只有 ADDED/SKIPPED/FAILED,没有 ±10% 残留。
+3. 同样 5 条跑药帮忙。
+4. 人工抽 2 条 ADDED:对照网页,商家是否 11 家之一、毛利是否 ≥5%、效期、库存。
+5. `--execute-cart --limit 1` 一个平台,打开购物车看 sku 和数量。
+6. 再跑同一条,确认不会把数量翻倍(幂等)。
+7. 再 `--limit 20` dry-run,看耗时。预估一千条:约 `20 条耗时 × 50`,写入结果 README。登录应只发生两次(每平台一次)。
+
+失败则停在当前步,不要跳到一千条。
+
+---
+
+## 7. 异常
+
+| 情况 | 处理 |
+| --- | --- |
+| 密码箱空 | 立即失败退出,不写半份结果 |
+| 登录失败 | 该平台本批中止;另一平台仍可跑(已登录的话) |
+| 单文号查询 3 次仍失败 | 该行该平台 FAILED,继续下一行 |
+| 加购超时 | FAILED `CART_UNKNOWN`,不要当 SKIPPED |
+| 极验登录点一次不够 | 沿用原等待逻辑;仍失败则整平台中止,记登录错误 |
+| 关闭 dry-run 但加购模块未实现 | 启动时报错退出,禁止假装 ADDED |
+
+脚本不点支付、不打开结算页。
+
+---
+
+## 8. Windows 任务清单
+
+按序做。每步可单独验证。
+
+### 任务 1:工作副本和输入模板
+
+- [ ] 拷贝同事工程到工作目录(仓库 `rpa_yisaiqi/procure_mall/` 或 Windows 本地)
+- [ ] 桌面建 `福建商城选品测试\选品输入.xlsx`,表头按 3.1
+- [ ] 放 5 条真实批准文号、销售价格、采购数量(问业务要,不要用假文号)
+- [ ] `uv run python -c "import isa_rpa, requests, openpyxl; print('ok')"` 能过
+
+### 任务 2:规则引擎 + 单测
+
+- [ ] 新建 `rules.py`、`tests/test_rules.py`
+- [ ] 实现规范化、毛利、效期、库存、择仓
+- [ ] `uv run pytest tests/test_rules.py -v` 全绿
+- [ ] 确认没有 import 原 ±10% 函数
+
+### 任务 3:搜品接到按行决策(仍不加购)
+
+- [ ] 拆出或保留 `query_yaoex_products` / `query_ybm_products`
+- [ ] 候选映射成 `Candidate`
+- [ ] `main.py` 改为按行循环,默认 dry-run
+- [ ] 删除 `filter_rows_by_purchase_price_band` 调用
+- [ ] 写新结果表(3.2)
+- [ ] 跑 `uv run main.py --dry-run --limit 5`
+- [ ] 人工核对 2 条 ADDED、2 条 SKIPPED
+
+### 任务 4:抓加购接口
+
+- [ ] 1 号药城 F12 抓「加入购物车」,写入 `CART_API.md`
+- [ ] 药帮忙同样写一份
+- [ ] 判定:接口可用 / 必须页面点击
+
+### 任务 5:实现加购(默认仍 dry-run)
+
+- [ ] `cart_yaoex.py` / `cart_ybm.py`
+- [ ] 先查车再加,避免数量翻倍
+- [ ] `--execute-cart --limit 1` 单平台
+- [ ] 人工打开购物车核对
+- [ ] 再跑同一条,数量不翻倍
+
+### 任务 6:收尾
+
+- [ ] README:命令、dry-run 默认、输入输出路径、密码箱名称
+- [ ] `--limit 20` dry-run 耗时写进 README
+- [ ] 把 `未匹配商家` 列表交给业务看要不要补别名(本期不改代码硬匹配)
+- [ ] 交付:工程、5 条结果 xlsx、加购成功截图、CART_API.md
+
+---
+
+## 9. 与后续 Oracle / 状态中心的边界
+
+本期 `decision` 字典就是以后 `PROCURE_ITEM_RUN` 的 `output_json`。字段先对齐:`result=ADDED|SKIPPED|FAILED`、原因、商家、sku、价格、数量。
+
+不要在本期接 FastAPI、不要写 MySQL、不要为选品新建 Oracle 表。下一期只换输入输出通道,不换规则和搜品。
+
+---
+
+## 10. 未决(不影响 Windows 开工)
+
+| 项 | 本期默认 | 谁拍板 |
+| --- | --- | --- |
+| 两平台都合格是否都加购 | 都加 | 业务;要互斥再改 `main` 循环 |
+| 毛利 刚好 5% | 算合格 `>=` | 已按设计说明「不低于 5%」 |
+| 商家名不完全等于 11 家 | 淘汰并记未匹配清单 | 业务补别名后再改 `WAREHOUSE_ORDER` 映射 |
+| 真正加购用测试账号还是正式采购账号 | 必须业务书面确认后再 `--execute-cart` | 业务 / 运维 |
+
+加购接口在改代码前未知,这是正常的。任务 4 的产出就是答案,不是阻塞任务 1~3 的理由。
diff --git a/docs/superpowers/specs/2026-09-14-jieda-procure-crawler-overview.md b/docs/superpowers/specs/2026-09-14-jieda-procure-crawler-overview.md
new file mode 100644
index 0000000..a13a4f1
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-14-jieda-procure-crawler-overview.md
@@ -0,0 +1,136 @@
+# 洁达选品:改造商城查价工程(概要)
+
+| 文档属性 | 内容 |
+| --- | --- |
+| 版本 | V1.0 |
+| 日期 | 2026-09-14 |
+| 状态 | 本期实施依据;Windows 机按本文和详细方案改同事原工程 |
+| 基线工程 | 同事「1号药城与药帮忙货品价格库存下载」`isa-rpa` Python 应用(`main.py`) |
+| 详细方案 | [2026-09-14-jieda-procure-crawler-detail.md](./2026-09-14-jieda-procure-crawler-detail.md) |
+| 业务口径 | [洁达四流程概要 V3.1](./2026-09-13-jieda-rpa-overview-design.md) 的 **BR-02 / BR-03 / 第 3.2 节** |
+| 选品条件原文 | [采购平台选品条件优先项 20260902](/Users/yuuuu/Documents/AutoRpa/资料/采购平台选品条件优先项_20260902.txt) |
+
+本期只做 **PROCURE 选品加购的第一刀**:在原查价工程上改,不接状态中心、不接 Oracle、不改合同/返利。先跑通「登录搜品 → 按行判定 → 合格加购」。
+
+---
+
+## 1. 要做成什么
+
+一次脚本运行:
+
+1. 读入待选品行(批准文号、销售价格、采购数量)。
+2. 浏览器登录 1 号药城、药帮忙各一次,拿 cookie/token。
+3. 按批准文号打现有搜索接口,拉候选(商家、价、效期、库存)。
+4. 用洁达选品规则,每个平台每行最多选出 **1 家仓**。
+5. 合格则加入该平台购物车;不合格记跳过;查询失败记失败。
+6. 写出结果表。人工付款仍由业务在平台做,脚本不等、不点支付。
+
+完成标准:Windows 机用 5~20 个品种能稳定跑完;结果表能看出每行 `ADDED / SKIPPED / FAILED`;合格行购物车里能看到对应商品和数量。默认 `--dry-run` 只判定不加购,确认规则后再关。
+
+## 2. 为什么改原工程,不另起
+
+原工程已经有:双平台登录、搜索 API、1 号药城价格解密、按文号缓存、接口重试。缺的是选品决策和加购。合同那套 Studio 逐步点击不适合一千个品种。
+
+原工程里必须删掉的:
+
+- `filter_rows_by_purchase_price_band`(采购价 ±10%)
+- 把所有命中行倒进 Excel 当最终答案
+- 用「采购价格」做是否合格的依据
+
+保留的:登录、`query_yaoex_products`、`query_ybm_products`、`decrypt_yaoex_price`、密码箱、重试。
+
+## 3. 架构
+
+```text
+Excel 输入(本期)
+ → 登录一次 / 平台
+ → 搜索接口(按批准文号,带缓存)
+ → 选品规则引擎(纯 Python,两平台同一套)
+ → 加购适配器(先接口,抓不到再点页面;仅合格行)
+ → 结果 Excel(每行一个平台一个结论)
+```
+
+平台只负责登录、搜索、读商品、执行加购。**不能改规则优先级。**
+
+本期输入仍用桌面 Excel,是为了在 Windows 上先试,不堵在 Oracle。列必须有 **销售价格**,不能再用采购价冒充。下一期再把输入/回报换成状态中心领取。
+
+## 4. 按行怎么跑
+
+一行输入 × 一个平台 = 一条结论。一次脚本把两个平台都跑完。
+
+```text
+登录两个商城(各一次)
+for 每一行输入:
+ for 平台 in [1号药城, 药帮忙]:
+ 搜批准文号
+ 规则选出 0 或 1 个仓
+ 合格 → 加购(dry-run 则跳过动作)→ ADDED
+ 无合格仓 → SKIPPED + 原因
+ 接口/登录级错误 → FAILED + 原因(不是 SKIPPED)
+写结果表,退出
+```
+
+**两边都合格就两边都加购。** 同一销售行可能进两个购物车。若业务要互斥,只改这一条策略,不要改规则引擎。
+
+登录失败整批中止。单个文号查询失败只失败该平台该行,继续后面的行。
+
+## 5. 合格标准(两平台同一套)
+
+按批准文号查询,不配规格、厂家、包装。前 4 项必须同时过,再按仓库名单取第一家。
+
+| 顺序 | 条件 | 判定 |
+| --- | --- | --- |
+| 1 | 自营仓 | 商家名规范化后,必须落在下列 11 家里 |
+| 2 | 价格 | `(销售价格 - 平台价) / 销售价格 >= 0.05`,即平台价 ≤ 销售价 × 0.95。基准是输入里的**销售价格** |
+| 3 | 效期 | ≥ 12 个月 |
+| 4 | 库存 | 平台库存 ≥ 本行采购数量 |
+| 5 | 仓库距离 | 在 1~4 都过的候选里,按下列顺序取第一家 |
+
+仓库顺序(不可改、不可按页面默认排序):
+
+1. 广东壹号药业有限公司
+2. 昆山亿方药业有限公司
+3. 湖北亿昊药业有限公司
+4. 天津壹号药业有限公司
+5. 重庆亿昊药业有限公司
+6. 重庆壹号医药有限公司
+7. 湖南壹号药业股份有限公司
+8. 辽宁亿昊药业有限公司
+9. 河南壹号医药有限公司
+10. 甘肃壹号云医药有限责任公司企业店
+11. 陕西壹号药业有限公司
+
+商家名先去掉 `[自营]`、首尾空格再比。对不上名单 = 本候选淘汰,不是整行失败。对不上的原始商家名写入结果,方便以后补别名。本期不做模糊匹配。
+
+效期:接口已带「12 个月」过滤,代码仍要再验。解析不了的效期,该候选淘汰。
+
+查询失败、登录失败、加购后不确定是否进车,记 `FAILED` / `UNKNOWN`,**禁止记成 SKIPPED**。
+
+## 6. 加购
+
+原工程没有购物车代码。合格之后才加购,数量 = 本行采购数量。
+
+1. 用 F12 抓「加入购物车」请求(详细方案有步骤)。
+2. 抓到则复用登录态打接口。
+3. 抓不到则只对合格行点页面,禁止一千条都去点搜索。
+4. `--dry-run` 默认开启:只写「将加购」不真正加。Windows 先用 dry-run 对规则,再用 1~2 行关 dry-run 验车。
+
+脚本不打开结算、不点支付。
+
+## 7. 本期不做
+
+- 状态中心领取 / start / complete / fail
+- Oracle 接口表、行 job
+- 平台订单导出、合同、返利
+- 人工付款、待复核清单替代加购(新方案是合格就加购)
+- 把查价 ±10% 留作并行规则
+
+## 8. Windows 交付物
+
+- 改过的 `isa-rpa` 工程(可运行)
+- 输入模板(含销售价格、采购数量、批准文号)
+- 结果表(每行每平台:结论、原因、选中商家、平台价、效期、库存、是否 dry-run)
+- 加购接口记录(URL、方法、关键字段;或「改用页面点击」的说明)
+- 用 5 个品种跑通的截图或结果文件
+
+详细到函数、表头、命令、测试步骤,见详细方案。
diff --git a/rpa_oms/tests/test_contract_agreement_outbound.py b/rpa_oms/tests/test_contract_agreement_outbound.py
index 43509da..143d9aa 100644
--- a/rpa_oms/tests/test_contract_agreement_outbound.py
+++ b/rpa_oms/tests/test_contract_agreement_outbound.py
@@ -78,6 +78,30 @@ def test_assembles_unpushed_rows_as_document_envelope():
assert isinstance(envelope["data"][0]["omsId"], int)
+def test_assembles_contract_success_and_agreement_rows():
+ repo = MemoryContractAgreementRepo(
+ [
+ _row(contract_status=2),
+ _row(
+ fid=300002,
+ action_code=2,
+ contract_status=None,
+ contract_no="HT1",
+ agreement_status=1,
+ agreement_id="AGR1",
+ agreement_dtl_id="AGRD1",
+ ),
+ ]
+ )
+ envelope = load_contract_agreement_envelope(repo)
+ assert envelope["data"][0]["actionCode"] == 1
+ assert envelope["data"][0]["contractStatus"] == 2
+ assert envelope["data"][1]["actionCode"] == 2
+ assert envelope["data"][1]["agreementStatus"] == 1
+ assert envelope["data"][1]["agreementId"] == "AGR1"
+ assert envelope["data"][1]["agreementDtlId"] == "AGRD1"
+
+
def test_oversized_contract_no_group_enters_when_batch_empty():
rows = [
_row(fid=1, oms_id=1, contract_no="HT-BIG"),
diff --git a/rpa_rebate/.env.example b/rpa_rebate/.env.example
new file mode 100644
index 0000000..7fd8cca
--- /dev/null
+++ b/rpa_rebate/.env.example
@@ -0,0 +1,5 @@
+REBATE_URL=http://192.168.170.144:8001
+REBATE_USERNAME=
+REBATE_PASSWORD=
+# 页面 ACCEPT 要搜的合同号;空则用哨兵号验收「搜不到」
+REBATE_ACCEPT_CONTRACT_NO=
diff --git a/rpa_rebate/README.md b/rpa_rebate/README.md
index e479add..91dc291 100644
--- a/rpa_rebate/README.md
+++ b/rpa_rebate/README.md
@@ -1,15 +1,44 @@
# rpa_rebate
-返利班。本包尚未实现。
+返利班。领表 4 起草行、表 3 只录协议行,按合同号查返利中心。搜到再录协议、写表 4。查号走页面;录协议仍用替身。接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status`。
| | |
| --- | --- |
-| 读 | 表 3 / 表 4:已有合同号尚未回合同成功的单,以及只录协议的单 |
-| 写 | 表 4:搜到合同号则 `contractStatus=2`;录协议后 `actionCode=2`、`agreementStatus=1` |
-| 口 | 口 4 |
+| 读 | 表 4 `action_code=1 AND contract_status=1`(已有合同号、尚未回成功);表 3 `contract_entry_status=0` 且已有 `contractNo` |
+| 写 | 表 4:搜到则起草行 `contractStatus=2`;录协议后插 `actionCode=2`、`agreementStatus=1` |
+| 口 | 口 4(组包在 oms,path 空) |
-与合同同一天两班,建议合同班跑完再跑。查号挂在本班,不单开查状态任务。搜不到不重录合同,留给下一班再搜。
+与合同同一天两班,建议合同班跑完再跑。查号挂在本班,不单开查状态任务。搜不到不重录合同,戳改回 `合同班`,留给下一班再搜。
+
+口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 5 节。
+
+## 运行
+
+内存替身(不连库、不开浏览器):
+
+```bash
+cd rpa_rebate
+uv sync --python 3.12
+uv run pytest tests/test_job.py::test_job_finds_contract_and_writes_agreement -q
+uv run pytest tests/test_finder.py::test_find_flow_writes_accept_for_missing_number -q
+```
+
+页面查号(不开录协议、不连 Oracle)。账号放本机 `.env`:
+
+```bash
+cd rpa_rebate
+uv run python run_find.py
+```
+
+验收看控制台 `ACCEPT SUMMARY REBATE_FIND` 或 `screenshots/REBATE_FIND/ACCEPT.txt`。默认搜哨兵号,期望 `found=no`。要搜真号:`REBATE_ACCEPT_CONTRACT_NO=合同号`。
+
+接口 4 组包(`rpa_oms`,不发 HTTP):
+
+```bash
+cd rpa_oms
+uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q
+```
## 本期不做
-返利中心查号、录协议、送审、接口 4 回写。
+录协议(总单 / 导入 / 送审)、映射表默认值、向订单中心发 HTTP、改 `push_status`。
diff --git a/rpa_rebate/accept.py b/rpa_rebate/accept.py
new file mode 100644
index 0000000..3ffc538
--- /dev/null
+++ b/rpa_rebate/accept.py
@@ -0,0 +1,153 @@
+"""Shared ACCEPT report for every RPA flow (any platform / page).
+
+New flows (ORDER_FETCH, REBATE_*, M5, ...) must use this instead of
+reading screenshots. Screenshots stay on disk for humans; this text is
+the agent oracle.
+"""
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Protocol
+
+from errors import NodeError
+
+PACKAGE_ROOT = Path(__file__).resolve().parent
+SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots"
+
+
+class HasScreenshot(Protocol):
+ def screenshot(self, name: str) -> str: ...
+
+
+@dataclass
+class AcceptLine:
+ step: str
+ title: str
+ ok: bool
+ facts: dict[str, str]
+ mismatches: list[str]
+
+
+def safe_flow_name(flow: str) -> str:
+ text = (flow or "default").strip() or "default"
+ return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text)
+
+
+def artifact_dir(flow: str) -> Path:
+ return SCREENSHOT_ROOT / safe_flow_name(flow)
+
+
+def evidence_shot(browser: HasScreenshot, name: str, *, failed: bool = False) -> str:
+ """Save a screenshot only on failure. Set RPA_SCREENSHOT=always to keep every frame."""
+ mode = (os.environ.get("RPA_SCREENSHOT") or "fail").strip().lower()
+ if not failed and mode != "always":
+ return ""
+ return browser.screenshot(name)
+
+
+def accept_log_path(flow: str = "default") -> Path:
+ override = os.environ.get("RPA_ACCEPT_LOG")
+ if override:
+ return Path(override)
+ return artifact_dir(flow) / "ACCEPT.txt"
+
+
+def field_match(actual: str, expected: str) -> bool:
+ actual_text = (actual or "").strip()
+ expected_text = (expected or "").strip()
+ if not expected_text:
+ return True
+ if actual_text == expected_text:
+ return True
+ compact_a = actual_text.replace(" ", "").replace(",", "")
+ compact_e = expected_text.replace(" ", "").replace(",", "")
+ if compact_a == compact_e or compact_a.replace("-", "") == compact_e.replace("-", ""):
+ return True
+ try:
+ return float(compact_a) == float(compact_e)
+ except ValueError:
+ pass
+ yes = {"是", "1", "Y", "YES", "TRUE"}
+ no = {"否", "2", "0", "N", "NO", "FALSE"}
+ if compact_a.upper() in yes and compact_e.upper() in yes:
+ return True
+ if compact_a.upper() in no and compact_e.upper() in no:
+ return True
+ return compact_e in compact_a or compact_a in compact_e
+
+
+def format_accept(line: AcceptLine) -> str:
+ status = "ok" if line.ok else "FAIL"
+ facts = " ".join(f"{key}={value}" for key, value in line.facts.items())
+ extra = f" {' | '.join(line.mismatches)}" if line.mismatches else ""
+ return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip()
+
+
+@dataclass
+class AcceptanceReport:
+ """DOM / page-state checklist. One instance per flow run."""
+
+ flow: str = "default"
+ lines: list[AcceptLine] = field(default_factory=list)
+
+ def reset(self) -> None:
+ self.lines = []
+
+ def check(
+ self,
+ step: str,
+ title: str,
+ pairs: list[tuple[str, object, object]],
+ *,
+ browser: HasScreenshot | None = None,
+ screenshot_on_fail: str = "",
+ ) -> None:
+ facts: dict[str, str] = {}
+ mismatches: list[str] = []
+ for name, actual, expected in pairs:
+ actual_text = "" if actual is None else str(actual).strip()
+ expected_text = "" if expected is None else str(expected).strip()
+ facts[name] = actual_text or "(empty)"
+ if expected_text and not field_match(actual_text, expected_text):
+ mismatches.append(f"{name} expected={expected_text!r} actual={actual_text!r}")
+ ok = not mismatches
+ self.lines.append(AcceptLine(step, title, ok, facts, mismatches))
+ print(format_accept(self.lines[-1]), flush=True)
+ self.dump()
+ if ok:
+ return
+ if browser is not None and screenshot_on_fail:
+ evidence_shot(browser, screenshot_on_fail, failed=True)
+ raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches))
+
+ def note(
+ self,
+ step: str,
+ title: str,
+ facts: dict[str, object],
+ *,
+ ok: bool = True,
+ raise_error: bool = True,
+ ) -> None:
+ rendered = {key: ("" if value is None else str(value).strip()) or "(empty)" for key, value in facts.items()}
+ self.lines.append(AcceptLine(step, title, ok, rendered, [] if ok else ["failed"]))
+ print(format_accept(self.lines[-1]), flush=True)
+ self.dump()
+ if not ok and raise_error:
+ raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items()))
+
+ def dump(self) -> None:
+ path = accept_log_path(self.flow)
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(self.summary_text(), encoding="utf-8")
+ except OSError as exc:
+ print(f"ACCEPT log write skipped: {exc}", flush=True)
+
+ def summary_text(self) -> str:
+ passed = sum(1 for line in self.lines if line.ok)
+ rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"]
+ rows.extend(format_accept(line) for line in self.lines)
+ return "\n".join(rows) + "\n"
diff --git a/rpa_rebate/apa_browser_adapter.py b/rpa_rebate/apa_browser_adapter.py
new file mode 100644
index 0000000..5a42c94
--- /dev/null
+++ b/rpa_rebate/apa_browser_adapter.py
@@ -0,0 +1,504 @@
+# coding=utf-8
+"""
+Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。
+"""
+from __future__ import annotations
+
+import os
+import time
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+def _is_inside_repo(path: Path, repo_root: Path) -> bool:
+ try:
+ path.resolve().relative_to(repo_root.resolve())
+ except ValueError:
+ return False
+ return True
+
+
+def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path:
+ """Chrome user-data dir must stay outside the repo so list_dir never walks 3GB."""
+ root = (repo_root or REPO_ROOT).resolve()
+ outside = (root.parent / "rpa-chrome-profile").resolve()
+ raw = os.getenv("RPA_CHROME_PROFILE") if requested is None else requested
+ if raw:
+ candidate = Path(raw).expanduser().resolve()
+ if _is_inside_repo(candidate, root):
+ print(f"RPA_CHROME_PROFILE 指向仓库内 {candidate},已改用 {outside}", flush=True)
+ return outside
+ return candidate
+ return outside
+
+
+class ApaBrowserAdapter:
+ """Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议"""
+
+ def __init__(self, url: str = "", *, flow: str = "default"):
+ self._url = url
+ self._flow = flow
+ self._driver = None
+ self._is_open = False
+
+ def _get_driver(self):
+ """创建或获取 Chrome WebDriver"""
+ if self._driver is not None:
+ return self._driver
+
+ from selenium import webdriver
+ from selenium.webdriver.chrome.service import Service
+ from selenium.webdriver.chrome.options import Options
+
+ profile_base = resolve_chrome_profile_base()
+ if _is_inside_repo(profile_base, REPO_ROOT):
+ raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_base}")
+ # 每次进程单独目录:上次 detach 留下的 Chrome 会锁住同一 profile
+ profile_dir = profile_base / str(os.getpid())
+ os.makedirs(profile_dir, exist_ok=True)
+ profile_dir = str(profile_dir)
+
+ options = Options()
+ options.page_load_strategy = "eager"
+ # 不自动关闭浏览器,方便调试
+ options.add_experimental_option("detach", True)
+ # 避免被检测为自动化
+ options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"])
+ options.add_experimental_option("useAutomationExtension", False)
+ options.add_argument("--disable-blink-features=AutomationControlled")
+ options.add_argument("--no-first-run")
+ options.add_argument("--no-default-browser-check")
+ options.add_argument("--disable-gpu")
+ options.add_argument("--disable-extensions")
+ options.add_argument("--disable-popup-blocking")
+ options.add_argument("--start-maximized")
+ # 独立目录,避免和日常 Chrome 抢用户数据目录导致卡住
+ options.add_argument("--user-data-dir=" + profile_dir)
+
+ chromedriver = os.getenv("CHROMEDRIVER_PATH")
+ if chromedriver and os.path.exists(chromedriver):
+ service = Service(chromedriver)
+ else:
+ from webdriver_manager.chrome import ChromeDriverManager
+ service = Service(ChromeDriverManager().install())
+ self._driver = webdriver.Chrome(service=service, options=options)
+ self._driver.set_page_load_timeout(45)
+ self._driver.set_script_timeout(30)
+ return self._driver
+
+ def _ensure_open(self):
+ if self._driver is None:
+ driver = self._get_driver()
+ if self._url:
+ driver.get(self._url)
+ self._is_open = True
+
+ # ---- 内部查找方法 ----
+
+ def _find(self, selector: str, timeout: int = 5):
+ """
+ 智能查找元素:支持 CSS、XPath、text=、label=、aria=、title=、:contains()
+ """
+ from selenium.webdriver.common.by import By
+ from selenium.common.exceptions import NoSuchElementException
+ import re
+
+ # 1. text=xxx → 查找包含对应文本的元素
+ if selector.startswith("text="):
+ text_val = selector[5:].strip('"').strip("'")
+ xpath = f'//*[contains(text(),"{text_val}")]'
+ return self._driver.find_element(By.XPATH, xpath)
+
+ # 2. label=xxx → 找到对应标签的输入框
+ if selector.startswith("label="):
+ text_val = selector[6:].strip('"').strip("'")
+ xpath = f'//label[contains(text(),"{text_val}")]/following-sibling::*[1][self::input or self::select or self::textarea]'
+ try:
+ return self._driver.find_element(By.XPATH, xpath)
+ except NoSuchElementException:
+ # Fallback: find the label's "for" attribute
+ try:
+ for_id = self._driver.find_element(
+ By.XPATH, f'//label[contains(text(),"{text_val}")]'
+ ).get_attribute("for")
+ if for_id:
+ return self._driver.find_element(By.ID, for_id)
+ except NoSuchElementException:
+ pass
+ raise
+
+ # 3. aria=xxx → aria-label 属性
+ if selector.startswith("aria="):
+ text_val = selector[5:].strip('"').strip("'")
+ return self._driver.find_element(By.CSS_SELECTOR, f'[aria-label="{text_val}"]')
+
+ # 4. title=xxx → title 属性
+ if selector.startswith("title="):
+ text_val = selector[6:].strip('"').strip("'")
+ return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]')
+
+ # 5. :contains() → 转 XPath(支持 button:contains(文本) / a:contains("文本"))
+ if ":contains(" in selector:
+ m = re.match(r'(.+?):contains\(["\']?(.+?)["\']?\)', selector)
+ if m:
+ tag_part, text_val = m.groups()
+ xpath = f'//{tag_part}[contains(.,"{text_val}")]'
+ return self._driver.find_element(By.XPATH, xpath)
+
+ # 6. 显式 XPath(以 / 或 // 开头)优先按 XPath 查找
+ if selector.startswith("//") or selector.startswith("/"):
+ return self._driver.find_element(By.XPATH, selector)
+
+ # 7. 标准 CSS selector
+ return self._driver.find_element(By.CSS_SELECTOR, selector)
+
+ # ---- BrowserAdapter 协议实现 ----
+
+ def open_url(self, url: str) -> None:
+ self._url = url
+ driver = self._get_driver()
+ try:
+ driver.get(url)
+ except Exception as exc:
+ # eager 仍可能因长轮询资源超时;页面文档已出来就继续
+ current = ""
+ try:
+ current = driver.current_url or ""
+ except Exception:
+ current = ""
+ if not current or current in ("data:,", "about:blank"):
+ raise RuntimeError(f"打开页面失败:{url} ({exc})") from exc
+ self._is_open = True
+
+ def _set_value_via_js(self, el, value: str) -> None:
+ """通过原生 value setter + 事件注入设置值,兼容 LUI/React 受控组件"""
+ self._driver.execute_script(
+ """
+ var el = arguments[0];
+ var value = arguments[1];
+ var proto = el.tagName === 'TEXTAREA'
+ ? window.HTMLTextAreaElement.prototype
+ : window.HTMLInputElement.prototype;
+ var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
+ setter.call(el, value);
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ el.dispatchEvent(new Event('blur', { bubbles: true }));
+ """,
+ el,
+ str(value),
+ )
+
+ def fill_first(self, selectors: list, value: str) -> None:
+ self._ensure_open()
+ last_error = None
+ for sel in selectors:
+ try:
+ el = self._find(sel)
+ # 先尝试原生输入;LUI 受控组件用 JS 注入兜底
+ try:
+ el.clear()
+ el.send_keys(str(value))
+ except Exception:
+ pass
+ if el.get_attribute("value") != str(value):
+ self._set_value_via_js(el, str(value))
+ time.sleep(0.3)
+ return
+ except Exception as e:
+ last_error = e
+ continue
+ raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}")
+
+ def click_native(self, selectors: list) -> None:
+ """真实鼠标点击,给只监听 mousedown 的 LUI 页签/工具栏用。"""
+ self._ensure_open()
+ from selenium.webdriver.common.action_chains import ActionChains
+ last_error = None
+ for sel in selectors:
+ try:
+ el = self._find(sel)
+ self._driver.execute_script(
+ "arguments[0].scrollIntoView({block:'center', inline:'center'});",
+ el,
+ )
+ time.sleep(0.2)
+ ActionChains(self._driver).move_to_element(el).pause(0.15).click().perform()
+ time.sleep(0.4)
+ return
+ except Exception as e:
+ last_error = e
+ continue
+ raise RuntimeError(f"click_native failed: {selectors}, last: {last_error}")
+
+ def click_first(self, selectors: list) -> None:
+ self._ensure_open()
+ last_error = None
+ for sel in selectors:
+ try:
+ el = self._find(sel)
+ # LUI/委托事件用 JS 点击最可靠
+ self._driver.execute_script("arguments[0].click()", el)
+ time.sleep(0.5)
+ return
+ except Exception as e:
+ last_error = e
+ continue
+ raise RuntimeError(f"click_first failed: {selectors}, last: {last_error}")
+
+ def double_click_first(self, selectors: list) -> None:
+ self._ensure_open()
+ from selenium.webdriver.common.action_chains import ActionChains
+ last_error = None
+ for sel in selectors:
+ try:
+ el = self._find(sel)
+ ActionChains(self._driver).double_click(el).perform()
+ time.sleep(0.5)
+ return
+ except Exception as e:
+ last_error = e
+ continue
+ raise RuntimeError(f"double_click_first failed: {selectors}, last: {last_error}")
+
+ def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
+ self._ensure_open()
+ from selenium.webdriver.support.ui import WebDriverWait
+ from selenium.webdriver.support import expected_conditions as EC
+ from selenium.webdriver.common.by import By
+ try:
+ WebDriverWait(self._driver, timeout_seconds).until(
+ EC.text_to_be_present_in_element((By.TAG_NAME, "body"), text)
+ )
+ except Exception as e:
+ raise TimeoutError(f"wait_for_text '{text}' timed out: {e}")
+
+ def wait_for_element(self, selectors: list, timeout_seconds: int = 15) -> None:
+ self._ensure_open()
+ deadline = time.time() + timeout_seconds
+ last_error = None
+ while time.time() < deadline:
+ for sel in selectors:
+ try:
+ self._find(sel, timeout=1)
+ return
+ except Exception as e:
+ last_error = e
+ time.sleep(0.5)
+ raise TimeoutError(f"wait_for_element timed out: {selectors}, last: {last_error}")
+
+ def element_exists(self, selectors: list) -> bool:
+ self._ensure_open()
+ for sel in selectors:
+ try:
+ self._find(sel, timeout=1)
+ return True
+ except Exception:
+ continue
+ return False
+
+ def switch_to_frame(self, selectors: list) -> None:
+ """切换到匹配到的 iframe。优先可见弹窗,避免切到上次留下的隐藏 iframe。"""
+ self._ensure_open()
+ from selenium.webdriver.common.by import By
+
+ last_error = None
+ found = []
+ for sel in selectors:
+ try:
+ if sel.startswith("//") or sel.startswith("/"):
+ found.extend(self._driver.find_elements(By.XPATH, sel))
+ else:
+ found.extend(self._driver.find_elements(By.CSS_SELECTOR, sel))
+ except Exception as e:
+ last_error = e
+ visible = []
+ hidden = []
+ for el in found:
+ try:
+ (visible if el.is_displayed() else hidden).append(el)
+ except Exception:
+ hidden.append(el)
+ for el in visible + hidden:
+ try:
+ self._driver.switch_to.default_content()
+ self._driver.switch_to.frame(el)
+ return
+ except Exception as e:
+ last_error = e
+ raise RuntimeError(f"switch_to_frame failed: {selectors}, last: {last_error}")
+
+ def switch_to_default_content(self) -> None:
+ """切回主文档(顶层 frame)。"""
+ self._ensure_open()
+ self._driver.switch_to.default_content()
+
+ def execute_script(self, script: str, *args):
+ self._ensure_open()
+ return self._driver.execute_script(script, *args)
+
+ def select_xform(self, name: str, value: str) -> None:
+ """选中 Landray xform fancy-select(.fs-wrap / .fs-option)。"""
+ self._ensure_open()
+ result = self._driver.execute_script(
+ """
+ const name = arguments[0];
+ const wanted = String(arguments[1] || '').trim();
+ const flag = document.querySelector('xformflag[property="' + name + '"]');
+ if (!flag) {
+ return {ok: false, error: 'flag not found: ' + name};
+ }
+ const norm = (s) => String(s || '').replace(/\\s+/g, '').trim();
+ const wantedN = norm(wanted);
+ const opts = Array.from(flag.querySelectorAll('.fs-option'));
+ let target = opts.find((o) => {
+ const text = norm((o.querySelector('.fs-option-label') || o).textContent);
+ const val = norm(o.getAttribute('data-value'));
+ return text === wantedN || val === wantedN;
+ });
+ if (!target && wantedN.length >= 2) {
+ target = opts.find((o) => {
+ const text = norm((o.querySelector('.fs-option-label') || o).textContent);
+ return text.indexOf(wantedN) >= 0;
+ });
+ }
+ if (!target) {
+ return {
+ ok: false,
+ error: 'option not found: ' + wanted,
+ available: opts.map((o) => ((o.querySelector('.fs-option-label') || o).textContent || '').trim()),
+ };
+ }
+ const labelWrap = flag.querySelector('.fs-label-wrap');
+ if (labelWrap) { labelWrap.click(); }
+ target.click();
+ const input = flag.querySelector('input[name="' + name + '"]');
+ const selected = target.getAttribute('data-value') || '';
+ if (input) {
+ const proto = window.HTMLInputElement.prototype;
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
+ setter.call(input, selected);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ if (typeof __xformDispatch === 'function') {
+ try { __xformDispatch(selected, input); } catch (e) {}
+ }
+ }
+ return {
+ ok: true,
+ value: selected,
+ label: ((flag.querySelector('.fs-label') || {}).textContent || '').trim(),
+ };
+ """,
+ name,
+ str(value),
+ )
+ if not result or not result.get("ok"):
+ raise RuntimeError(f"select_xform failed: {name}={value} {result}")
+ time.sleep(0.3)
+
+ def select_first(self, selectors: list, value: str) -> None:
+ self._ensure_open()
+ from selenium.webdriver.support.ui import Select
+ last_error = None
+ for sel in selectors:
+ try:
+ el = self._find(sel)
+ # 原生