feat: 班次可重跑,避免对方单据重复
领取 UPDATE 按 500 个 fid 分段,同一事务,任一段失败整单回滚。选品一行一落并续租,系统故障改回待领。页面和商城调用期间归还连接。坏连接丢弃后再借一次,未提交语句只重试一次。 加购登录或查询失败放回待领。传入购物车读数且已够量则不再加车。合同按「我的合同」三态:没有则起草,唯一则复用合同号,多条保持处理中。返利送审前按合同号查已有总单,读到则补写不再新建;读号失败保持处理中。 同一次提交带上本分支尚未入库的 Oracle 连接层(rpa_oracle / rpa_browser)及各班次改接。
This commit is contained in:
parent
ac19e60eb5
commit
586db4be90
|
|
@ -24,6 +24,7 @@ node_modules/
|
||||||
.runtime/
|
.runtime/
|
||||||
rpa_contract/screenshots/
|
rpa_contract/screenshots/
|
||||||
rpa_rebate/screenshots/
|
rpa_rebate/screenshots/
|
||||||
|
rpa_cart/exports/
|
||||||
rpa-chrome-profile/
|
rpa-chrome-profile/
|
||||||
**/chrome-profile/
|
**/chrome-profile/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|
|
||||||
97
AGENTS.md
97
AGENTS.md
|
|
@ -18,28 +18,31 @@
|
||||||
|
|
||||||
| 问题 | 打开 | 验收 |
|
| 问题 | 打开 | 验收 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 接口1 收件契约 | `rpa_oms/app/inbound/sales_goods.py`、`envelope.py` | `rpa_oms/tests/test_sales_goods_receive.py::点名` |
|
| 接口1 收件契约 | `rpa_oms/app/inbound/sales_goods.py`、`envelope.py`、`parse.py` | `rpa_oms/tests/test_sales_goods_receive.py::点名` |
|
||||||
| 接口1 Oracle 落表 | `rpa_oms/app/inbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_repo.py::点名` |
|
| 接口1 Oracle 落表 | `rpa_oms/app/inbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_repo.py::点名` |
|
||||||
| 接口1 入口 / 鉴权 | `rpa_oms/app/main.py`、`auth.py` | 同上;真库见 `rpa_oms/README.md` Windows 段 |
|
| 接口1 入口 / 鉴权 / 审计 | `rpa_oms/app/main.py`、`auth.py`、`inbound/audit.py` | 同上;真库见 `rpa_oms/README.md` 运维 |
|
||||||
| 接口2 取数组装 | `rpa_oms/app/outbound/cart_settlement.py` | `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope` |
|
| 接口2 取数组装 | `rpa_oms/app/outbound/cart_settlement.py` | `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope` |
|
||||||
|
| 接口2 Oracle 取数 | `rpa_oms/app/outbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_cart_repo.py::test_list_unpushed_keeps_whole_oms_group_when_limit_is_3` |
|
||||||
| 接口4 取数组装 | `rpa_oms/app/outbound/contract_agreement.py` | `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope` |
|
| 接口4 取数组装 | `rpa_oms/app/outbound/contract_agreement.py` | `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope` |
|
||||||
|
| 接口4 Oracle 取数 | `rpa_oms/app/outbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_agreement_repo.py::test_list_unpushed_keeps_whole_contract_no_group_when_limit_is_3` |
|
||||||
| 接口3 收件契约 | `rpa_oms/app/inbound/purchase_contract.py` | `rpa_oms/tests/test_purchase_contract_receive.py::点名` |
|
| 接口3 收件契约 | `rpa_oms/app/inbound/purchase_contract.py` | `rpa_oms/tests/test_purchase_contract_receive.py::点名` |
|
||||||
| 接口3 Oracle 落表 | `rpa_oms/app/inbound/oracle_contract_repo.py` | `rpa_oms/tests/test_oracle_contract_repo.py::点名` |
|
| 接口3 Oracle 落表 | `rpa_oms/app/inbound/oracle_contract_repo.py` | `rpa_oms/tests/test_oracle_contract_repo.py::点名` |
|
||||||
| 选品规则 / 拆量 | `rpa_selection/rules.py`、`pipeline.py` | `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` |
|
| 选品规则 / 拆量 | `rpa_selection/rules.py`、`pipeline.py` | `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` |
|
||||||
| 选品班次落表 | `rpa_selection/job.py`、`persist.py`、`repo.py` | `rpa_selection/tests/test_job.py::test_job_searches_barcode_not_approval_no` |
|
| 选品班次落表 | `rpa_selection/job.py`、`persist.py`、`repo.py` | `rpa_selection/tests/test_job.py::test_job_searches_barcode_not_approval_no` |
|
||||||
| 合同班合单落表 | `rpa_contract/job.py`、`group.py`、`persist.py`、`repo.py` | `rpa_contract/tests/test_job.py::test_job_merges_same_supplier_and_writes_table4` |
|
| 合同班合单落表 | `rpa_contract/job.py`、`group.py`、`persist.py`、`repo.py` | `rpa_contract/tests/test_job.py::test_job_merges_same_supplier_and_writes_table4` |
|
||||||
| 合同页面步骤 | `rpa_contract/contract_flow_steps.py` 里对应函数 | `ACCEPT SUMMARY` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt` |
|
| 合同页面步骤 | `rpa_contract/contract_login.py`(S02–S05)/ `contract_form.py`(S06–S08、S10)/ `contract_goods.py`(S09)/ `contract_upload.py`(S11)/ `contract_submit.py`(S12)/ `contract_number.py`(S13);共用 `contract_common.py`;入口 `contract_flow_steps.py` | `ACCEPT SUMMARY` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt` |
|
||||||
| 合同入参 / 客商草稿 | `rpa_contract/contract_draft.py` | `rpa_contract` 点名用例 |
|
| 合同入参 / 客商草稿 | `rpa_contract/contract_draft.py` | `rpa_contract` 点名用例 |
|
||||||
| 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 |
|
| 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 |
|
||||||
| 浏览器适配 | `rpa_contract/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.py::点名` |
|
| 浏览器适配 | `rpa_browser/adapter.py`;返利 junction/口在 `rpa_rebate/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.py` |
|
||||||
| 文字验收 / 截图策略 | `rpa_contract/accept.py` | 只读 `ACCEPT.txt`,禁止读 png |
|
| 文字验收 / 截图策略 | `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/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_cart/job.py`、`settle.py` | 1 号药城支付按 Excel 落表,不对加购行 |
|
||||||
| 返利班查号落表 | `rpa_rebate/job.py`、`persist.py`、`repo.py` | `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` |
|
| 返利班查号落表 | `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_rebate/rebate_common.py` | 四个节点共用;改这里四个一起变 |
|
||||||
| 返利总单页面 | `rpa_rebate/rebate_flow_steps.py`、`run_header.py` | `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no` |
|
| 返利班查号页面 | `rpa_rebate/rebate_find.py`、`rebate_finder.py` | `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/tests/test_finder.py::test_find_flow_writes_accept_for_missing_number` |
|
||||||
| 返利细单新增 | `rpa_rebate/rebate_flow_steps.py`、`run_import.py` | `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail` |
|
| 返利总单页面 | `rpa_rebate/rebate_header.py`、`run_header.py` | `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no` |
|
||||||
| 返利送审 | `rpa_rebate/rebate_flow_steps.py`、`run_submit.py` | `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos` |
|
| 返利细单新增 | `rpa_rebate/rebate_import.py`、`run_import.py` | `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail` |
|
||||||
|
| 返利送审 | `rpa_rebate/rebate_submit.py`、`run_submit.py` | `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos` |
|
||||||
| 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — |
|
| 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — |
|
||||||
| 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` |
|
| 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` |
|
||||||
|
|
||||||
|
|
@ -49,26 +52,30 @@
|
||||||
|
|
||||||
订单中心四接口,本期开放接口 1、3 收件。鉴权 header `X-Oms-Token`(`OMS_INBOUND_TOKEN`)。接口 1:`POST /api/oms/v1/sales-goods`,落表 `rpa_sales_goods_mapping_data`,`deal_flag=0`,**请求里不跑选品**。接口 3:`POST /api/oms/v1/purchase-contracts`,落表 `rpa_purchase_contract_data`,`process_status=0`,按对方 `fid` 幂等,**请求里不录合同**。接口 2 只从表 2 取 `push_status=0` 组装报文,路径仍空,不发 HTTP。
|
订单中心四接口,本期开放接口 1、3 收件。鉴权 header `X-Oms-Token`(`OMS_INBOUND_TOKEN`)。接口 1:`POST /api/oms/v1/sales-goods`,落表 `rpa_sales_goods_mapping_data`,`deal_flag=0`,**请求里不跑选品**。接口 3:`POST /api/oms/v1/purchase-contracts`,落表 `rpa_purchase_contract_data`,`process_status=0`,按对方 `fid` 幂等,**请求里不录合同**。接口 2 只从表 2 取 `push_status=0` 组装报文,路径仍空,不发 HTTP。
|
||||||
|
|
||||||
运行时只落 Oracle。单测用内存替身,不连库。服务端口 **8001**。
|
运行时只落 Oracle。入站单测用假连接录 SQL,不连库。服务端口 **8001**。连接从 `rpa_oracle` 池借,一个请求一条。
|
||||||
|
|
||||||
| 步 | 函数 | 要点 |
|
| 步 | 函数 | 要点 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 信封 | `parse_envelope` | 缺 `data` / 空数组 / 超过 100 条:整包 422,一条都不落 |
|
| 信封 | `parse_envelope` | 缺 `data` / 空数组 / 超过 100 条:整包 422,一条都不落 |
|
||||||
| 逐条校验 | `receive_sales_goods` | 入参 camelCase,落表 snake_case;长度跟 VARCHAR2 |
|
| 整批校验 | `receive_sales_goods` | 内存 REJECTED / 同批 CONFLICT,再一次查重一次 `executemany` |
|
||||||
| 鉴权 | `token_ok` | 未配 token 一律失败 |
|
| 鉴权 | `token_ok` | `X-Oms-Token` 共享密钥,未配一律失败;失败进审计 |
|
||||||
| 落表 | `OracleSalesGoodsRepo.insert` | `fid` 由序列触发器生成,INSERT 不绑这一列 |
|
| 落表 | `existing_oms_ids` + `insert_many` | `fid` 由序列触发器生成,INSERT 不绑这一列;只认 `uk_rpa_sgmd_oms_id` |
|
||||||
| 幂等 | 同 `omsId` | 已存在 → `DUPLICATE`,不更新、不重跑 |
|
| 幂等 | 同 `omsId` | 已存在 → `DUPLICATE`,不更新、不重跑 |
|
||||||
| 同批冲突 | 同一 POST 里 `omsId` 再现 | 后一条 `CONFLICT`,只落第一条 |
|
| 同批冲突 | 同一 POST 里 `omsId` 再现 | 后一条 `CONFLICT`,只落第一条 |
|
||||||
| 字段不过 | `_parse_row` | `REJECTED`,不入库 |
|
| 字段不过 | `_parse_row` | `REJECTED`,不入库 |
|
||||||
| 混批 | `post_sales_goods` | 逐条有成功/重复/拒绝时 HTTP 仍 200,看 `results[].resultCode` |
|
| 混批 | `post_sales_goods` | 逐条有成功/重复/拒绝时 HTTP 仍 200,看 `results[].resultCode` |
|
||||||
|
| 对外文案 | `messages.py` | 401「身份认证失败」;503「数据库不可用」;body 不含 ORA |
|
||||||
|
| 审计 | `write_audit` | 一行请求一条;token 打码;写失败不把业务打成 500 |
|
||||||
|
|
||||||
`omsId` / `saleQty` / `planPurchaseQty` / 接口 3 的 `fid` 必须是 JSON 整数,不把 `"10"` 当 10。Windows 连真库测;Instant Client 位数与 Python 一致。
|
`omsId` / `saleQty` / `planPurchaseQty` / 接口 3 的 `fid` 必须是 JSON 整数,不把 `"10"` 当 10。Windows 连真库测;Instant Client 位数与 Python 一致。
|
||||||
|
|
||||||
接口 2:`load_cart_settlement_envelope` 捞 `push_status=0`,同一 `omsId` 整组进一批,上限 500;金额四位小数字符串。不改 `push_status`,不 POST 订单中心。
|
接口 2:`load_cart_settlement_envelope` 捞 `push_status=0`,同一 `omsId` 整组进一批,上限默认 500(`OMS_OUTBOUND_BATCH_LIMIT`),SQL 侧截组;金额四位小数字符串,NULL 金额写成 null,不抛整批。连接从 `rpa_oracle` 池借,`get_pool` 懒加载加锁。`mark_pushed` 已备,不 POST 订单中心。
|
||||||
|
|
||||||
接口 3:`receive_purchase_contracts` 按 `fid` 幂等;同批 `fid` 再现 → `CONFLICT`;`contractEntryStatus=0` 必须带 `contractNo`;结束日不得早于开始日。`INSERT` 绑 `fid`。
|
接口 3:`receive_purchase_contracts` 按 `fid` 幂等;同批 `fid` 再现 → `CONFLICT`;`contractEntryStatus=0` 必须带 `contractNo`;结束日不得早于开始日。`INSERT` 绑 `fid`。形状与接口 1 相同:内存整批 → `existing_fids` → `insert_many`。
|
||||||
|
|
||||||
验收(内存、不连库):`cd rpa_oms` → `uv run pytest tests/test_sales_goods_receive.py::点名 -q`。真库:`GET /health` 后把 `examples/sales_goods_batch.json` POST 两次(第一次两条 `ACCEPTED`,第二次两条 `DUPLICATE`)。接口 3 点名 `tests/test_purchase_contract_receive.py`;真库 POST `examples/purchase_contract_batch.json` 两次。接口 2 点名 `tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。
|
接口 4:`load_contract_agreement_envelope` 捞 `push_status=0`,同一非空 `contractNo` 整组进一批,上限同样默认 500,SQL 侧截组。空 `contractNo` 按行独立。`mark_pushed` 已备,不 POST。
|
||||||
|
|
||||||
|
验收(假连接、不连库):`cd rpa_oms` → `uv run pytest tests/test_sales_goods_receive.py -q`。真库:`GET /health` 后把 `examples/sales_goods_batch.json` POST 两次(第一次两条 `ACCEPTED`,第二次两条 `DUPLICATE`)。接口 3 点名 `tests/test_purchase_contract_receive.py`;真库 POST `examples/purchase_contract_batch.json` 两次。接口 2 点名 `tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。
|
||||||
|
|
||||||
修接口1不要顺手改 `rpa_selection/`、`rpa_cart/`、`rpa_contract/`、`rpa_rebate/`。接口 2、4 只组装不推送。
|
修接口1不要顺手改 `rpa_selection/`、`rpa_cart/`、`rpa_contract/`、`rpa_rebate/`。接口 2、4 只组装不推送。
|
||||||
|
|
||||||
|
|
@ -80,9 +87,9 @@
|
||||||
|
|
||||||
| 步 | 函数 | 要点 |
|
| 步 | 函数 | 要点 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 领取 | `claim_pending` | `deal_flag` 0→3;中途崩溃停在 3,改回 0 再跑 |
|
| 领取 | `claim_pending` | `deal_flag` 0→3;`FOR UPDATE SKIP LOCKED`;中途崩溃停在 3,超过租约(`deal_time` 30 分钟)可再领 |
|
||||||
| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利 ≥ 4%、效期 ≥ 12 月;同仓只留低价;按仓序拆量 |
|
| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利严格大于 4%、效期 ≥ 12 月;同仓多个 sku 都参与拆量,低价先吃;按仓序拆量 |
|
||||||
| 落表 | `build_result` + `save_result` | 成功每仓一行 `status=1`,合计=计划量;`push_status=0` |
|
| 落表 | `build_result` + `save_result` | 成功每条拆量一行 `status=1`,合计=计划量;`push_status=0` |
|
||||||
| 库存不足 | `KIND_BUSINESS_FAIL` | 两边都搜到但合计不够:`deal_flag=2`,该 `omsId` 不重跑 |
|
| 库存不足 | `KIND_BUSINESS_FAIL` | 两边都搜到但合计不够:`deal_flag=2`,该 `omsId` 不重跑 |
|
||||||
| 对端故障 | `KIND_SYSTEM_FAIL` | 一边故障且剩下的凑不齐:可把 `deal_flag` 改回 0;禁止写成库存不足 |
|
| 对端故障 | `KIND_SYSTEM_FAIL` | 一边故障且剩下的凑不齐:可把 `deal_flag` 改回 0;禁止写成库存不足 |
|
||||||
|
|
||||||
|
|
@ -92,38 +99,38 @@
|
||||||
|
|
||||||
## 加购落点
|
## 加购落点
|
||||||
|
|
||||||
班次作业在 `rpa_cart/`,不在选品请求里跑。领表 2 `operate_type=1 AND status=1 AND push_status=0 AND update_user_name=系统管理员`。登录 1 号药城 / 药帮忙,按条形码再搜,对上 `supplier_name`,按行加车。
|
班次作业在 `rpa_cart/`,不在选品请求里跑。领表 2 `operate_type=1 AND status=1 AND push_status=0`,戳为 `系统管理员` 或过期的 `加购中`。登录 1 号药城 / 药帮忙,按条形码再搜,对上 `supplier_name`,按行加车。
|
||||||
|
|
||||||
| 步 | 函数 | 要点 |
|
| 步 | 函数 | 要点 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 领取 | `claim_pending` | 盖 `加购中`;崩溃停在这戳,改回 `系统管理员` 再跑 |
|
| 领取 | `claim_pending` | 盖 `加购中`;`FOR UPDATE SKIP LOCKED`;中途崩溃停在这戳,超过租约(`update_time` 30 分钟)可再领 |
|
||||||
| 对仓 | `match_offer` | 条形码结果对商家全称;同仓留低价 |
|
| 对仓 | `match_offer` | 条形码结果对商家全称;同仓留低价 |
|
||||||
| 加车 | `run_cart_batch` + 平台 `add_*_cart` | 改数量加入购物车。无 `sku_id`,必须再搜 |
|
| 加车 | `run_cart_batch` + 平台 `add_*_cart` | 改数量加入购物车。无 `sku_id`,必须再搜 |
|
||||||
| 成功 | `mark_success` | 不插行,`update_user_name=加购班`,`status` 仍 1 |
|
| 成功 | `mark_success` | 不插行,`update_user_name=加购班`,`status` 仍 1 |
|
||||||
| 失败 | `mark_fail` | 该行 `status=2` + 原因。登录挂写系统故障;库存/中包装/对不上仓写业务原因 |
|
| 失败 | `mark_fail` | 该行 `status=2` + 原因。登录挂写 `LOGIN_FAILED`;库存/中包装/对不上仓写业务原因 |
|
||||||
|
|
||||||
同一 `omsId` 拆两仓要加齐;一行失败只改那一行。不推接口 2,不清空购物车,不碰合同、表 1。接口 2 仍捞全部 `push_status=0`,加购班跑完之前不要组装。
|
同一 `omsId` 拆两仓要加齐;一行失败只改那一行。同组里有成功又有失败时,失败行原因带「未加齐」,不回退已成功的行。不推接口 2,不清空购物车,不碰合同、表 1。接口 2 仍捞全部 `push_status=0`,加购班跑完之前不要组装。
|
||||||
|
|
||||||
验收:`cd rpa_cart` → `uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q`(杭州 3 + 广东 7,不插新行)。
|
验收:`cd rpa_cart` → `uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q`(杭州 3 + 广东 7,不插新行)。
|
||||||
|
|
||||||
## CONTRACT_CREATE 落点
|
## CONTRACT_CREATE 落点
|
||||||
|
|
||||||
班次作业在 `rpa_contract/`,不在接口 3 请求里跑。领表 3 `process_status=0`(跳过协议重录行),按 `supplierId` 合单,调页面流,写表 4。`run_contract_create_flow` 串页面;修一步只打开对应函数。
|
班次作业在 `rpa_contract/`,不在接口 3 请求里跑。领表 3 `process_status=0`(跳过协议重录行),按 `supplierId` + `org_code` + 签订日/开始日/结束日合单,任一不同则各自成组,调页面流,写表 4。`run_contract_create_flow` 串页面;修一步只打开对应文件。`contract_flow_steps.py` 仍是对外入口。
|
||||||
|
|
||||||
| 步 | 函数 | 要点 |
|
| 步 | 打开 | 要点 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| S02 打开 | `open_contract_system` | `http://contract-test.gzmpc.com:8080/` |
|
| S02 打开 | `contract_login.py` `open_contract_system` | `http://contract-test.gzmpc.com:8080/` |
|
||||||
| S03 登录 | `login_contract_system` | `10007228` / `1` |
|
| S03 登录 | `contract_login.py` `login_contract_system` | 账密只认 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD` |
|
||||||
| S04–S05 起草 | `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
|
| S04–S05 起草 | `contract_login.py` `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
|
||||||
| S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
|
| S06 签约方 | `contract_form.py` `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
|
||||||
| 尽调 | `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
|
| 尽调 | `contract_form.py` `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
|
||||||
| S07 基础 | `fill_contract_basic_info` | 标题 `_expected_title` |
|
| S07 基础 | `contract_form.py` `fill_contract_basic_info` | 标题 `_expected_title` |
|
||||||
| S08 金额日期 | `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` |
|
| S08 金额日期 | `contract_form.py` `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` |
|
||||||
| S09 货品 | `fill_contract_goods_lines` | 弹窗按操作码搜 |
|
| S09 货品 | `contract_goods.py` `fill_contract_goods_lines` | 弹窗按操作码搜,找不到则本单失败 |
|
||||||
| S10 付款 | `fill_contract_payment_info` | |
|
| S10 付款 | `contract_form.py` `fill_contract_payment_info` | |
|
||||||
| S11 PDF | `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF |
|
| S11 PDF | `contract_upload.py` `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF |
|
||||||
| S12 提交 | `submit_contract_for_approval` | 成功后**不要等详情页** |
|
| S12 提交 | `contract_submit.py` `submit_contract_for_approval` | 成功后**不要等详情页** |
|
||||||
| S13 合同号 | `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 |
|
| S13 合同号 | `contract_number.py` `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 |
|
||||||
|
|
||||||
成功路径默认不截图(`RPA_SCREENSHOT=always` 才全截)。页面等待用 `wait_for_text` / `wait_for_element`,不要固定长睡。
|
成功路径默认不截图(`RPA_SCREENSHOT=always` 才全截)。页面等待用 `wait_for_text` / `wait_for_element`,不要固定长睡。
|
||||||
|
|
||||||
|
|
@ -133,24 +140,24 @@
|
||||||
|
|
||||||
| 步 | 函数 | 要点 |
|
| 步 | 函数 | 要点 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 领取 | `claim_pending` | 表 4 盖 `查号中`;表 3 `process_status` 0→3。崩溃停戳,改回 `合同班` / `0` 再跑 |
|
| 领取 | `claim_pending` | 表 4 `process_status` 0→3,同一合同号整组;表 3 `process_status` 0→3;`FOR UPDATE SKIP LOCKED`;中途崩溃停在 3,超过租约(表 4 `update_time` / 表 3 `process_time` 30 分钟)可再领 |
|
||||||
| 查号 | `RebateFinder` / `search_contract` | 打开 `REBATE_URL` → 登录 → 采购协议 → 按合同号搜;表里出现该号才 `True`,输入框回显不算 |
|
| 查号 | `RebateFinder` / `search_contract` | 打开 `REBATE_URL` → 登录 → 采购协议 → 按合同号搜;表里出现该号才 `True`,输入框回显不算 |
|
||||||
| 录协议 | `recorder` | 搜到后总单 → 细单新增(按货品ID,不做 Excel)→ 送审;送审成功才插 `actionCode=2` |
|
| 录协议 | `recorder` | 搜到后总单 → 细单新增(按货品ID,不做 Excel)→ 送审;送审成功才插 `actionCode=2` |
|
||||||
| 落表 | `build_found` + `save_group` | 起草行 `contract_status=2`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带总单/细单号;`push_status=0` |
|
| 落表 | `build_found` + `save_group` | 送审成功:起草行 `contract_status=2`、`process_status=1`,每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带总单/细单号。失败:起草行 `contract_status` 保持 1、`process_status` 改回 0,写 `agreement_fail_reason`,下一班还能领。`push_status=0`。人名固定 `系统管理员` |
|
||||||
| 搜不到 | `build_not_found` | 戳改回 `合同班`;表 3 改回 `0`;不插协议行 |
|
| 搜不到 | `build_not_found` | 表 4 `process_status` 改回 `0`;表 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`。总单:`uv run python run_header.py`,只读 `ACCEPT SUMMARY REBATE_HEADER`。细单新增:`uv run python run_import.py`,只读 `ACCEPT SUMMARY REBATE_IMPORT`。送审:`uv run python run_submit.py`,只读 `ACCEPT SUMMARY REBATE_SUBMIT`。接口 4:`cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`。现行字段与步骤见 `rpa_rebate/现行模式.md`、`rpa_rebate/返利流程.md`。
|
验收:`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`。总单:`uv run python run_header.py`,只读 `ACCEPT SUMMARY REBATE_HEADER`。细单新增:`uv run python run_import.py`,只读 `ACCEPT SUMMARY REBATE_IMPORT`。送审:`uv run python run_submit.py`,只读 `ACCEPT SUMMARY REBATE_SUBMIT`。接口 4:`cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`。现行字段与步骤见 `rpa_rebate/现行模式.md`、`rpa_rebate/返利流程.md`。
|
||||||
|
|
||||||
不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。
|
不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。货源细单ID 本期不填(弹层按联系人苏文辉过滤)。计划员现阶段廖建琦。
|
||||||
|
|
||||||
## 返利中心及之后
|
## 返利中心及之后
|
||||||
|
|
||||||
查号页面已接 `REBATE_FIND`。总单页面已接 `REBATE_HEADER`。细单新增已接 `REBATE_IMPORT`(节点名仍叫 IMPORT,页面走新增,不做 Excel)。送审已接 `REBATE_SUBMIT`。班次串查号 → 总单 → 细单新增 → 送审,送审成功才写 `actionCode=2`。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。
|
查号页面已接 `REBATE_FIND`。总单页面已接 `REBATE_HEADER`。细单新增已接 `REBATE_IMPORT`(节点名仍叫 IMPORT,页面走新增,不做 Excel)。送审已接 `REBATE_SUBMIT`。班次串查号 → 总单 → 细单新增 → 送审,送审成功才写 `actionCode=2`。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按节点打开 `rebate_find.py` / `rebate_header.py` / `rebate_import.py` / `rebate_submit.py`,共用登录/查号/勾行/弹层打开 `rebate_common.py`。`rebate_flow_steps.py` 仍是对外入口。验收只看 ACCEPT / 点名用例。
|
||||||
|
|
||||||
| 节点 | 谁做 | 要回传 | 页面要做什么 |
|
| 节点 | 谁做 | 要回传 | 页面要做什么 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 |
|
| `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 |
|
||||||
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:搜到后填映射表默认值 → 保存拿总单号 |
|
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:勾已有总单,按 `返利流程.md` 填现行字段 → 保存拿协议总单ID |
|
||||||
| `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel |
|
| `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel |
|
||||||
| `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 |
|
| `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 |
|
||||||
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
|
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
|
||||||
|
|
@ -162,7 +169,7 @@
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
- 接口1:点名 `rpa_oms/tests/test_sales_goods_receive.py` 或 `test_oracle_repo.py`;禁止为「显得全面」跑整包再去连真库。
|
- 接口1:点名 `rpa_oms/tests/test_sales_goods_receive.py` 或 `test_oracle_repo.py`;禁止为「显得全面」跑整包再去连真库。
|
||||||
- 接口2:点名 `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。
|
- 接口2:点名 `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`;SQL 截组 `tests/test_oracle_cart_repo.py::test_list_unpushed_keeps_whole_oms_group_when_limit_is_3`。
|
||||||
- 接口3:点名 `rpa_oms/tests/test_purchase_contract_receive.py` 或 `test_oracle_contract_repo.py`。
|
- 接口3:点名 `rpa_oms/tests/test_purchase_contract_receive.py` 或 `test_oracle_contract_repo.py`。
|
||||||
- 选品:点名 `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。
|
- 选品:点名 `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_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` 或 `test_job_marks_out_of_stock`。
|
||||||
|
|
@ -172,8 +179,8 @@
|
||||||
- 返利总单页:只读控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/screenshots/REBATE_HEADER/ACCEPT.txt`;点名 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no`。
|
- 返利总单页:只读控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/screenshots/REBATE_HEADER/ACCEPT.txt`;点名 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no`。
|
||||||
- 返利细单新增:只读控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/screenshots/REBATE_IMPORT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail`。
|
- 返利细单新增:只读控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/screenshots/REBATE_IMPORT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail`。
|
||||||
- 返利送审:只读控制台 `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/screenshots/REBATE_SUBMIT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos`。
|
- 返利送审:只读控制台 `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/screenshots/REBATE_SUBMIT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos`。
|
||||||
- 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。
|
- 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`;SQL 截组 `tests/test_oracle_agreement_repo.py::test_list_unpushed_keeps_whole_contract_no_group_when_limit_is_3`。
|
||||||
- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。
|
- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`;步骤门闸点名 `rpa_contract/tests/test_flow_gates.py`。
|
||||||
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。
|
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。
|
||||||
|
|
||||||
## 不要
|
## 不要
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -21,7 +21,7 @@
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。接口 1、3 收件;接口 2、4 组装报文 |
|
| `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。接口 1、3 收件;接口 2、4 组装报文 |
|
||||||
| `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
|
| `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
|
||||||
| `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 |
|
| `rpa_cart/` | 加购已接;1 号药城支付落表。表 2 → 口 2 仍未推 |
|
||||||
| `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 |
|
| `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 |
|
||||||
| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号 → 总单 → 细单新增 → 送审同一班 |
|
| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号 → 总单 → 细单新增 → 送审同一班 |
|
||||||
|
|
||||||
|
|
@ -36,6 +36,18 @@
|
||||||
|
|
||||||
新接口只进 `rpa_oms/`。起服务见 [`rpa_oms/README.md`](rpa_oms/README.md)。
|
新接口只进 `rpa_oms/`。起服务见 [`rpa_oms/README.md`](rpa_oms/README.md)。
|
||||||
|
|
||||||
|
## 本期未做
|
||||||
|
|
||||||
|
| 项 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 返利货源细单ID | 弹层按总单联系人(现苏文辉)过滤;本期不填,见 `rpa_rebate/返利流程.md` |
|
||||||
|
| 返利计划员 | 现阶段「廖建琦」,后期改回表 3 `planner_name` |
|
||||||
|
| 口 2 / 口 4 HTTP | path 空,只组包不发 |
|
||||||
|
| 药帮忙支付 | 只做了 1 号药城 |
|
||||||
|
| M5 | 录单停、不保存;收货通知单号人填 |
|
||||||
|
|
||||||
|
大批量现场还缺调度、崩溃扫戳、清车、合同生产地址,见 [`docs/大批量跑缺口.md`](docs/大批量跑缺口.md)。
|
||||||
|
|
||||||
## 文档入口
|
## 文档入口
|
||||||
|
|
||||||
现行口径和表:[`docs/README.md`](docs/README.md)。
|
现行口径和表:[`docs/README.md`](docs/README.md)。
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@
|
||||||
| `API鉴权.doc` | 调订单中心时的 Token;接口 1、3 入站鉴权由本方定 |
|
| `API鉴权.doc` | 调订单中心时的 Token;接口 1、3 入站鉴权由本方定 |
|
||||||
| `下一会话任务_建表.md` | 建表任务说明(已完成) |
|
| `下一会话任务_建表.md` | 建表任务说明(已完成) |
|
||||||
| `合同系统录入流程.md` | 合同班合单、尽调、提交、回写;页面步骤指向 `rpa_contract` |
|
| `合同系统录入流程.md` | 合同班合单、尽调、提交、回写;页面步骤指向 `rpa_contract` |
|
||||||
|
| `../rpa_rebate/返利流程.md` | 返利页面现行(总单/细单/送审)。与映射表冲突听这份 |
|
||||||
|
| `../rpa_rebate/现行模式.md` | 返利班相对旧稿改了什么、下一会话未做 |
|
||||||
|
| `大批量跑缺口.md` | 四条班次相对大批量现场还缺什么(口 2/4 HTTP、调度、崩溃戳、支付/M5) |
|
||||||
|
| `写死测试配置整改清单.md` | 上正式前要拆的测试站、测试账号和干跑回退 |
|
||||||
|
|
||||||
接口字段、示例报文仍以 Excel 为准(当前 `Downloads/RPA与订单中心接口设计_20260922.xlsx`)。Excel 与口径冲突时,作业语义听口径。
|
接口字段、示例报文仍以 Excel 为准(当前 `Downloads/RPA与订单中心接口设计_20260922.xlsx`)。Excel 与口径冲突时,作业语义听口径。
|
||||||
|
|
||||||
|
|
@ -22,7 +26,7 @@
|
||||||
|
|
||||||
| 文件 | 用途 |
|
| 文件 | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `合同返利系统字段映射_20260911.xlsx` | 返利总单默认值 |
|
| `合同返利系统字段映射_20260911.xlsx` | 返利旧默认值;现行字段以 `rpa_rebate/返利流程.md` 为准 |
|
||||||
| `采购平台选品条件优先项_20260902.txt` | 选品规则原文 |
|
| `采购平台选品条件优先项_20260902.txt` | 选品规则原文 |
|
||||||
| `【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | 页面操作参考 |
|
| `【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | 页面操作参考 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
|
|
||||||
- `Downloads/RPA与订单中心接口设计_20260922.xlsx`(以当时最新一份为准)
|
- `Downloads/RPA与订单中心接口设计_20260922.xlsx`(以当时最新一份为准)
|
||||||
- `docs/API鉴权.doc`
|
- `docs/API鉴权.doc`
|
||||||
- `docs/合同返利系统字段映射_20260911.xlsx`(返利总单默认值)
|
- `docs/合同返利系统字段映射_20260911.xlsx`(返利旧默认值,对照用)
|
||||||
|
- `rpa_rebate/返利流程.md`(返利页面现行;与映射表冲突听这份)
|
||||||
- `rpa_selection/` 既有规则
|
- `rpa_selection/` 既有规则
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -135,7 +136,7 @@
|
||||||
- 搜到:先回合同成功,再录协议
|
- 搜到:先回合同成功,再录协议
|
||||||
- 搜不到:本班不录协议、不重开合同,下一班再搜
|
- 搜不到:本班不录协议、不重开合同,下一班再搜
|
||||||
|
|
||||||
总单默认值跟映射表(计划员用订单中心给的,承付「下月 N 号结」,协议结束当天等)。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。
|
总单/细单现行字段以 `rpa_rebate/返利流程.md` 为准:计划员现阶段「廖建琦」(后期改回订单中心),联系人「苏文辉」,结算「电汇」,承付「下月 N 号结」参数 `25`,协议开始/结束当天。细单走页面新增(按 `goods_id` 搜),不做 Excel。货源细单ID 本期不填。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。映射表作对照,冲突听返利流程。
|
||||||
|
|
||||||
### 5.3 协议「成功」
|
### 5.3 协议「成功」
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,7 @@ CREATE TABLE rpa_contract_agreement_result (
|
||||||
update_user_id NUMBER(19,0),
|
update_user_id NUMBER(19,0),
|
||||||
update_user_name VARCHAR2(100 CHAR),
|
update_user_name VARCHAR2(100 CHAR),
|
||||||
org_code VARCHAR2(10),
|
org_code VARCHAR2(10),
|
||||||
|
process_status NUMBER(1,0) DEFAULT 0 NOT NULL,
|
||||||
CONSTRAINT pk_rpa_car PRIMARY KEY (fid)
|
CONSTRAINT pk_rpa_car PRIMARY KEY (fid)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -316,3 +317,121 @@ COMMENT ON COLUMN rpa_contract_agreement_result.create_user_name IS '创建人
|
||||||
COMMENT ON COLUMN rpa_contract_agreement_result.update_user_id IS '更新人ID。';
|
COMMENT ON COLUMN rpa_contract_agreement_result.update_user_id IS '更新人ID。';
|
||||||
COMMENT ON COLUMN rpa_contract_agreement_result.update_user_name IS '更新人名称。';
|
COMMENT ON COLUMN rpa_contract_agreement_result.update_user_name IS '更新人名称。';
|
||||||
COMMENT ON COLUMN rpa_contract_agreement_result.org_code IS '货主编码。';
|
COMMENT ON COLUMN rpa_contract_agreement_result.org_code IS '货主编码。';
|
||||||
|
COMMENT ON COLUMN rpa_contract_agreement_result.process_status IS '返利处理状态。0-待查号,1-返利已回写,2-合同失败不进返利,3-查号中。不进接口4。';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 入站审计(一行请求一条;写失败不影响业务 HTTP)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE rpa_oms_inbound_audit (
|
||||||
|
fid NUMBER(19) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
api_no NUMBER(1) NOT NULL,
|
||||||
|
http_code NUMBER(3) NOT NULL,
|
||||||
|
request_json CLOB,
|
||||||
|
response_json CLOB,
|
||||||
|
error_text VARCHAR2(4000),
|
||||||
|
token_masked VARCHAR2(8),
|
||||||
|
CONSTRAINT pk_rpa_oms_in_aud PRIMARY KEY (fid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SEQUENCE seq_rpa_oms_in_aud
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NOMAXVALUE
|
||||||
|
NOCYCLE
|
||||||
|
NOCACHE;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_rpa_oms_in_aud_bi
|
||||||
|
BEFORE INSERT ON rpa_oms_inbound_audit
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (new.fid IS NULL)
|
||||||
|
BEGIN
|
||||||
|
SELECT seq_rpa_oms_in_aud.NEXTVAL INTO :NEW.fid FROM dual;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
COMMENT ON TABLE rpa_oms_inbound_audit IS '订单中心入站审计。一行请求一条;token 只存打码。';
|
||||||
|
COMMENT ON COLUMN rpa_oms_inbound_audit.fid IS '本方序列主键。';
|
||||||
|
COMMENT ON COLUMN rpa_oms_inbound_audit.created_at IS '请求处理时间。';
|
||||||
|
COMMENT ON COLUMN rpa_oms_inbound_audit.api_no IS '口。1-销售商品,3-采购合同。';
|
||||||
|
COMMENT ON COLUMN rpa_oms_inbound_audit.http_code IS '对外 HTTP 状态码。';
|
||||||
|
COMMENT ON COLUMN rpa_oms_inbound_audit.request_json IS '入参 JSON。不含 token 明文。';
|
||||||
|
COMMENT ON COLUMN rpa_oms_inbound_audit.response_json IS '出参 JSON。';
|
||||||
|
COMMENT ON COLUMN rpa_oms_inbound_audit.error_text IS '内部错误原文,含 ORA。对外 503 不用这一列。';
|
||||||
|
COMMENT ON COLUMN rpa_oms_inbound_audit.token_masked IS '有 token 则为 ***。';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 选品查询失败审计(一次查询失败一条;写失败不影响班次落表)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE rpa_selection_query_audit (
|
||||||
|
fid NUMBER(19) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
oms_id NUMBER(20) NOT NULL,
|
||||||
|
mapping_fid NUMBER(19) NOT NULL,
|
||||||
|
mall_name VARCHAR2(64) NOT NULL,
|
||||||
|
bar_code VARCHAR2(40),
|
||||||
|
error_text VARCHAR2(4000),
|
||||||
|
CONSTRAINT pk_rpa_sel_qry_aud PRIMARY KEY (fid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SEQUENCE seq_rpa_sel_qry_aud
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NOMAXVALUE
|
||||||
|
NOCYCLE
|
||||||
|
NOCACHE;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_rpa_sel_qry_aud_bi
|
||||||
|
BEFORE INSERT ON rpa_selection_query_audit
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (new.fid IS NULL)
|
||||||
|
BEGIN
|
||||||
|
SELECT seq_rpa_sel_qry_aud.NEXTVAL INTO :NEW.fid FROM dual;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
COMMENT ON TABLE rpa_selection_query_audit IS '选品查询失败审计。一次平台查询失败一条;不含业务短码。';
|
||||||
|
COMMENT ON COLUMN rpa_selection_query_audit.fid IS '本方序列主键。';
|
||||||
|
COMMENT ON COLUMN rpa_selection_query_audit.created_at IS '写入时间。';
|
||||||
|
COMMENT ON COLUMN rpa_selection_query_audit.oms_id IS '订单中心序号。';
|
||||||
|
COMMENT ON COLUMN rpa_selection_query_audit.mapping_fid IS '表 1 rpa_sales_goods_mapping_data.fid。';
|
||||||
|
COMMENT ON COLUMN rpa_selection_query_audit.mall_name IS '平台名。';
|
||||||
|
COMMENT ON COLUMN rpa_selection_query_audit.bar_code IS '查询用的条形码。';
|
||||||
|
COMMENT ON COLUMN rpa_selection_query_audit.error_text IS '异常原文,含 ORA。超长截断。不进 deal_result。';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 加购 / 合同 / 返利未捕获异常审计(一条故障一条;写失败不影响班次落表)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE rpa_shift_error_audit (
|
||||||
|
fid NUMBER(19) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
shift_name VARCHAR2(32) NOT NULL,
|
||||||
|
biz_key VARCHAR2(128),
|
||||||
|
error_code VARCHAR2(64) NOT NULL,
|
||||||
|
error_text VARCHAR2(4000),
|
||||||
|
CONSTRAINT pk_rpa_shift_err_aud PRIMARY KEY (fid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SEQUENCE seq_rpa_shift_err_aud
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NOMAXVALUE
|
||||||
|
NOCYCLE
|
||||||
|
NOCACHE;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_rpa_shift_err_aud_bi
|
||||||
|
BEFORE INSERT ON rpa_shift_error_audit
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (new.fid IS NULL)
|
||||||
|
BEGIN
|
||||||
|
SELECT seq_rpa_shift_err_aud.NEXTVAL INTO :NEW.fid FROM dual;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
COMMENT ON TABLE rpa_shift_error_audit IS '加购、合同、返利未捕获异常审计。业务列仍写短码。';
|
||||||
|
COMMENT ON COLUMN rpa_shift_error_audit.fid IS '本方序列主键。';
|
||||||
|
COMMENT ON COLUMN rpa_shift_error_audit.created_at IS '写入时间。';
|
||||||
|
COMMENT ON COLUMN rpa_shift_error_audit.shift_name IS '班次。加购、合同或返利。';
|
||||||
|
COMMENT ON COLUMN rpa_shift_error_audit.biz_key IS '加购行 fid、合同供应商或返利合同号。';
|
||||||
|
COMMENT ON COLUMN rpa_shift_error_audit.error_code IS '业务列上的短码,如 QUERY_FAILED、PAGE_FAILED。';
|
||||||
|
COMMENT ON COLUMN rpa_shift_error_audit.error_text IS '异常原文,含 ORA。超长截断。不进失败原因列。';
|
||||||
|
|
|
||||||
|
|
@ -165,5 +165,6 @@ Excel 待确认:重录协议是否新 `fid`;税率百分数还是小数;
|
||||||
| 18 | 更新人ID | update_user_id | number(19,0) | 同 | 无 |
|
| 18 | 更新人ID | update_user_id | number(19,0) | 同 | 无 |
|
||||||
| 19 | 更新人名称 | update_user_name | varchar2(100 char) | 同 | 无 |
|
| 19 | 更新人名称 | update_user_name | varchar2(100 char) | 同 | 无 |
|
||||||
| 20 | 货主编码 | org_code | varchar2(10) | 同 | 无 |
|
| 20 | 货主编码 | org_code | varchar2(10) | 同 | 无 |
|
||||||
|
| — | 返利处理状态 | process_status | — | NUMBER(1,0) DEFAULT 0 NOT NULL | 本方补。0 待查号,1 返利已回写,2 合同失败不进返利,3 查号中。不进接口 4 |
|
||||||
|
|
||||||
相对接口 sheet:数据表多 `push_*`、审计字段。
|
相对接口 sheet:数据表多 `push_*`、审计字段、`process_status`。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
# 班次大批量收口(打勾用)
|
||||||
|
|
||||||
|
改完把 `- [ ]` 改成 `- [x]`,勾上日期。一次只开一条。说「做 BATCH-01」再动代码。
|
||||||
|
|
||||||
|
建议顺序:BATCH-01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 10。
|
||||||
|
|
||||||
|
目标:一个班次可以安全地重复跑,对方系统里的单据不因重跑而多出一份。
|
||||||
|
|
||||||
|
口径不变:接口 1 不跑选品;接口 3 不录合同;选品拆量规则不动;合同 S02–S13 填表步骤不动;接口 2、4 这轮不接推送。
|
||||||
|
|
||||||
|
已定,不要再讨论:
|
||||||
|
|
||||||
|
- 领取 `UPDATE` 的 `IN` 每段 500 个 `fid`,同一事务,任一段失败整笔回滚。调用方 `limit` 仍管这一班做多少。
|
||||||
|
- 班次池保持 `min=1, max=1`。还没 `commit` 的失败:`drop` 坏连接,再借一次,只重试这一笔。已提交的不重做。`ping_interval=60`。
|
||||||
|
- 序列保持 `NOCACHE`。这轮不改 DDL,不加注释。
|
||||||
|
- `rpa_oms_inbound_audit`、`rpa_selection_query_audit`、`rpa_shift_error_audit` 这轮不清理。
|
||||||
|
- 合同查重三态:0 条才起草,恰好 1 条才复用合同号,多条停在 `process_status=3`。标题是客商名 + 「采购协议」 + 签订日,不含货主和起止日,标题 + 客商不是合单键。
|
||||||
|
|
||||||
|
**注释门禁(每条收工都查):** 本条改过或新建的模块、类、每一个函数/方法(含 `_` 私有、测试函数)都要有 docstring。第一句写做什么,随后只补签名里看不出的规则。不用 Args/Returns/Raises 小标题。缺一条不算完成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 领取分段,入口封顶
|
||||||
|
|
||||||
|
### BATCH-01 领取 UPDATE 分段
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_oracle` 放共用分段更新。`rpa_selection/repo.py`、`rpa_cart/cart_repo.py`、`rpa_contract/repo.py`、`rpa_rebate/repo.py` 的领取 `UPDATE` 改走它。
|
||||||
|
- 改成:每段 500 个 `fid`,同一事务,任一段失败整笔回滚。`rpa_rebate/repo.py` 的 `_take_draft_groups` 第一组仍整组领,只把更新拆段。
|
||||||
|
- 验收:领取 1001 条时第一段能领走,不报「数据库不可用」。返利一个合同号超过 1000 行仍能整组领走。点名加在各自 `test_oracle_repo.py` / `test_cart_repo.py`。
|
||||||
|
|
||||||
|
### BATCH-02 入口默认上限
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_selection/main.py`、`rpa_cart/main.py`、`rpa_rebate/run_batch.py`、`rpa_contract/run_batch.py`。
|
||||||
|
- 改成:不传 `--limit` 用默认值,不再表示全部领走。选品 50,加购 20,合同和返利按行 10。合同入口补上 `--limit`。
|
||||||
|
- 验收:四个入口不传参时领取条数不超过默认值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 按单落库,页面期间不占连接
|
||||||
|
|
||||||
|
### BATCH-03 选品一行一落,续租,重领不重插
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_selection/job.py` 的 `run_batch`,`rpa_selection/repo.py` 的 `save_results`。
|
||||||
|
- 改成:一行搜完就 `save_results` 这一行。搜索过程中刷新 `deal_time`。落库前再读:仍是本进程领的 `deal_flag=3`,并且表 2 还没有该 `oms_id` 的加购行,才插入。
|
||||||
|
- 验收:`rpa_selection/tests/test_job.py` 点名。租约过期后第二个进程不再插入第二套表 2。
|
||||||
|
|
||||||
|
### BATCH-04 页面期间归还连接
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_contract/job.py`、`rpa_rebate/job.py`、`rpa_cart/cart_job.py` 的 `session()`。
|
||||||
|
- 改成:`session()` 只包住领取和落库,不包住页面或商城调用。一组做完就提交。
|
||||||
|
- 验收:对应 `test_job.py` / `test_cart_job.py`。页面执行期间连接已归还。
|
||||||
|
|
||||||
|
### BATCH-05 坏连接丢弃后再借一次
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_oracle/pool.py`。
|
||||||
|
- 改成:`create_pool` 写 `ping_interval=60`。未提交失败时 `drop` 坏连接,再借一次,只重试这一笔。注释写明班次池只有一条,靠探活和丢弃换连接。班次池保持 `min=1, max=1`。
|
||||||
|
- 验收:`rpa_oracle/tests/test_pool.py`。探活失败或执行前连接已死时,下一次借到新连接。已提交的语句不重做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 瞬时故障保持可再领
|
||||||
|
|
||||||
|
### BATCH-06 选品系统故障改回待领
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_selection/persist.py`。
|
||||||
|
- 改成:`LOGIN_FAILED` / `QUERY_FAILED` 且凑不齐时,`deal_flag` 改回 0,不写表 2。库存不足、毛利、效期仍写 `deal_flag=2`。
|
||||||
|
- 验收:`rpa_selection/tests/test_job.py` 里系统故障用例改断言。业务失败用例仍是终态。
|
||||||
|
|
||||||
|
### BATCH-07 加购登录和查询失败放回队列
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_cart/cart_job.py`。
|
||||||
|
- 改成:登录失败和查询失败把戳改回「系统管理员」,`status` 保持 1。对不上仓、库存不足仍写 `status=2`。
|
||||||
|
- 验收:`rpa_cart/tests/test_cart_job.py`。登录失败的行下一班还能领。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 重跑不产生第二份外部单据
|
||||||
|
|
||||||
|
### BATCH-08 加购前查车
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_cart/cart_job.py` 的 `_add_one`。
|
||||||
|
- 改成:加车前按条形码和供应商查购物车。数量已经不少于本行,直接 `mark_success`。多出来的数量不当成另一张单。
|
||||||
|
- 验收:`rpa_cart/tests/test_cart_job.py`。对方购物车已有本行数量、本地仍是「加购中」时,重跑不加第二份。
|
||||||
|
|
||||||
|
### BATCH-09 合同查重三态
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_contract/run_batch.py` 的 `production_runner`。每个合单组开始前把浏览器拉回起草页。
|
||||||
|
- 改成:进起草前查「我的合同」。0 条才走 S06–S12;恰好 1 条只落表 4;多条停在 `process_status=3`,不新建,也不取最新一条。提交后读不到号也停在 3,下一班只查号。注释写明三态,以及标题不含货主和起止日。
|
||||||
|
- 验收:`rpa_contract/tests/test_job.py`。对方已有唯一合同号、本地没写上时,重跑不起草第二份。命中多条时停在处理中。
|
||||||
|
|
||||||
|
### BATCH-10 返利按合同号查重
|
||||||
|
|
||||||
|
- [x] 2026-10-24
|
||||||
|
- 落点:`rpa_rebate/job.py` 的 `recorder`,`rpa_rebate/persist.py` 的 `build_found`。
|
||||||
|
- 改成:送审前按合同号查已有协议。已有总单号就补写 `action_code=2`,不再新建。只在确认对方没有单据时,才把起草行改回 `process_status=0`。读号失败保持处理中。
|
||||||
|
- 验收:`rpa_rebate/tests/test_job.py`。对方已有协议、本地没写上时,重跑不产生第二份。
|
||||||
|
|
@ -18,9 +18,9 @@
|
||||||
|
|
||||||
1. 硬过滤:自营仓、毛利 ≥ 4%、效期 ≥ 12 个月。库存不再是过滤项。
|
1. 硬过滤:自营仓、毛利 ≥ 4%、效期 ≥ 12 个月。库存不再是过滤项。
|
||||||
2. 按四级仓序(同仓低价、再高库存)排序。
|
2. 按四级仓序(同仓低价、再高库存)排序。
|
||||||
3. 同一平台同一仓只留 1 条候选。
|
3. 同一平台同一仓的多个 sku 都参与拆量,不再只留低价那条。
|
||||||
4. 按序贪心拆量,直到计划采购量凑齐。
|
4. 按序贪心拆量,直到计划采购量凑齐。
|
||||||
5. 凑齐:每个仓一行写入表 2,`status=1`,数量是该仓拆到的件数,合计等于计划量。
|
5. 凑齐:每条拆量一行写入表 2,`status=1`,数量是该条拆到的件数,合计等于计划量。
|
||||||
6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。
|
6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。
|
||||||
|
|
||||||
完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。
|
完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。
|
||||||
|
|
@ -33,7 +33,6 @@
|
||||||
- 接口 2 推送(`push_status` 仍为 0)
|
- 接口 2 推送(`push_status` 仍为 0)
|
||||||
- 批准文号搜索、商家名模糊匹配
|
- 批准文号搜索、商家名模糊匹配
|
||||||
- 中包装 / 起订量
|
- 中包装 / 起订量
|
||||||
- 同一仓多 SKU 拆成两行
|
|
||||||
- 改表结构、改接口 1
|
- 改表结构、改接口 1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -94,11 +93,11 @@
|
||||||
- `EXPIRY_UNPARSEABLE` / `EXPIRY_LT_12M`
|
- `EXPIRY_UNPARSEABLE` / `EXPIRY_LT_12M`
|
||||||
- `NO_SALE_PRICE`(采购行级,先于搜品)
|
- `NO_SALE_PRICE`(采购行级,先于搜品)
|
||||||
|
|
||||||
库存为 `None` 或向下取整后 `<= 0`:可以过过滤,但折叠和拆量时当作 0,拆不到量。
|
库存为 `None` 或向下取整后 `<= 0`:可以过过滤,但拆量时当作 0,拆不到量。
|
||||||
|
|
||||||
### 5.2 折叠
|
### 5.2 同仓
|
||||||
|
|
||||||
`key = (mall_name, vendor_norm)`。同一 key 多条时保留 `(price, -stock)` 最优的一条。其余记入 rejected,原因 `SAME_WAREHOUSE_NOT_BEST`。
|
不再按 `(mall_name, vendor_norm)` 折成一条。过过滤的 sku 全部进入拆量。同仓低价先吃,不够再吃同仓下一条;仓序仍优先于跨仓低价。不再产生 `SAME_WAREHOUSE_NOT_BEST`。
|
||||||
|
|
||||||
### 5.3 排序
|
### 5.3 排序
|
||||||
|
|
||||||
|
|
@ -166,8 +165,8 @@ for candidate in sorted_passed:
|
||||||
| `supplier_name` | `vendor_raw` |
|
| `supplier_name` | `vendor_raw` |
|
||||||
| `bar_code` / `org_code` | 原样 |
|
| `bar_code` / `org_code` | 原样 |
|
||||||
| `purchase_price` | 该候选平台价,四位小数 |
|
| `purchase_price` | 该候选平台价,四位小数 |
|
||||||
| `purchase_qty` | 该仓 `take`,**不是**计划全量 |
|
| `purchase_qty` | 该条 `take`,**不是**计划全量 |
|
||||||
| `purchase_total` | 单价 × 该仓数量,四位小数 |
|
| `purchase_total` | 单价 × 该条数量,四位小数 |
|
||||||
| `status` | 1 |
|
| `status` | 1 |
|
||||||
| `fail_reason` | null |
|
| `fail_reason` | null |
|
||||||
| `push_status` | 0 |
|
| `push_status` | 0 |
|
||||||
|
|
@ -177,7 +176,7 @@ for candidate in sorted_passed:
|
||||||
|
|
||||||
业务失败:每个**已启用且已判定**的平台落一行 `status=2`,`purchase_qty` 仍用计划量(造数校验:数量 > 0),`purchase_price` 仍用销售价兜底。`supplier_name` 仍为「无符合规则供应商」。`multi_platform_flag` 按失败行平台数(≥2 个平台则为 1)。
|
业务失败:每个**已启用且已判定**的平台落一行 `status=2`,`purchase_qty` 仍用计划量(造数校验:数量 > 0),`purchase_price` 仍用销售价兜底。`supplier_name` 仍为「无符合规则供应商」。`multi_platform_flag` 按失败行平台数(≥2 个平台则为 1)。
|
||||||
|
|
||||||
业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0、以及被 `SAME_WAREHOUSE_NOT_BEST` 折叠掉但仍算「该仓有过过滤候选」——以折叠**之前**过过滤的集合为准):
|
业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0。以过过滤的集合为准):
|
||||||
|
|
||||||
1. `NO_BAR_CODE` / `NO_SALE_PRICE` / `NO_QTY`:沿用现有中文
|
1. `NO_BAR_CODE` / `NO_SALE_PRICE` / `NO_QTY`:沿用现有中文
|
||||||
2. **过过滤集合非空,但拆不满**:一律 `无符合规则商品,库存不足`。rejected 里即使同时有非自营、效期、毛利淘汰,也不改这条。
|
2. **过过滤集合非空,但拆不满**:一律 `无符合规则商品,库存不足`。rejected 里即使同时有非自营、效期、毛利淘汰,也不改这条。
|
||||||
|
|
@ -200,7 +199,7 @@ for candidate in sorted_passed:
|
||||||
|
|
||||||
| 文件 | 改什么 |
|
| 文件 | 改什么 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `rules.py` | 新名单;`MIN_MARGIN=0.04`;`evaluate_candidate` 去掉库存;新增 `allocate()`(过滤+折叠+排序+拆量);`select_warehouse` 删除或改为调用 `allocate` 后取第一仓——**禁止**再作为班次入口 |
|
| `rules.py` | 新名单;`MIN_MARGIN=0.04`;`evaluate_candidate` 去掉库存;新增 `allocate()`(过滤+同仓全留+排序+拆量);`select_warehouse` 删除或改为调用 `allocate` 后取第一仓——**禁止**再作为班次入口 |
|
||||||
| `pipeline.py` | 新增 `run_selection(purchase, mall_outcomes)`,一次产出整单结果。`run_one_platform` 只留给单测过渡,班次不再调用 |
|
| `pipeline.py` | 新增 `run_selection(purchase, mall_outcomes)`,一次产出整单结果。`run_one_platform` 只留给单测过渡,班次不再调用 |
|
||||||
| `job.py` | 先收集各平台搜索结果,再 `run_selection` |
|
| `job.py` | 先收集各平台搜索结果,再 `run_selection` |
|
||||||
| `persist.py` | 吃整单结果:成功按拆量多行;`multi_platform_flag` 按平台种类数;失败原因 5% → 4%,并增加库存不足 |
|
| `persist.py` | 吃整单结果:成功按拆量多行;`multi_platform_flag` 按平台种类数;失败原因 5% → 4%,并增加库存不足 |
|
||||||
|
|
@ -234,7 +233,7 @@ class SelectionResult:
|
||||||
mall_errors: dict[str, str] # 平台 → LOGIN_FAILED:... / QUERY_FAILED:...
|
mall_errors: dict[str, str] # 平台 → LOGIN_FAILED:... / QUERY_FAILED:...
|
||||||
```
|
```
|
||||||
|
|
||||||
`reason` 在 `run_selection` / `allocate` 里用折叠**前**过过滤集合算完。persist 只做中文映射,禁止从 `rejected` 反推库存不足(库存为 0 的过过滤候选不在 rejected 里)。
|
`reason` 在 `run_selection` / `allocate` 里用过过滤集合算完。persist 只做中文映射,禁止从 `rejected` 反推库存不足(库存为 0 的过过滤候选不在 rejected 里)。
|
||||||
|
|
||||||
`kind` 决定落表,不要用「有没有 FAILED 决策」去猜:
|
`kind` 决定落表,不要用「有没有 FAILED 决策」去猜:
|
||||||
|
|
||||||
|
|
@ -257,7 +256,7 @@ class SelectionResult:
|
||||||
- 湖南壹号、甘肃壹号云视为非自营
|
- 湖南壹号、甘肃壹号云视为非自营
|
||||||
- 近仓 3、次仓 20、计划 10 → 近仓 3 + 次仓 7
|
- 近仓 3、次仓 20、计划 10 → 近仓 3 + 次仓 7
|
||||||
- 合计 9、计划 10 → 无 allocation
|
- 合计 9、计划 10 → 无 allocation
|
||||||
- 同一仓两 SKU(价 90 库存 5、价 92 库存 100)、计划 10 → 只用价 90 那条的 5,余量 5 给下一仓,不吃同仓 92 的库存
|
- 同一仓两 SKU(价 90 库存 5、价 92 库存 100)、计划 10 → 价 90 拿 5,价 92 拿 5,不把余量给下一仓
|
||||||
- 库存 3.9 按 3 件拆
|
- 库存 3.9 按 3 件拆
|
||||||
|
|
||||||
班次 / 落表:
|
班次 / 落表:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 合同 / 返利共用的 Selenium Chrome 适配器。
|
||||||
|
#
|
||||||
|
# Chrome 用户目录必须在仓库外。调试口和 DevTools 就绪用短超时轮询,不 sleep(1)。
|
||||||
|
# 返利班的用户 Chrome junction / 端口差异留在 rpa_rebate.apa_browser_adapter。
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
ApaBrowserAdapter,
|
||||||
|
REPO_ROOT,
|
||||||
|
chrome_debug_port,
|
||||||
|
launch_independent_chrome,
|
||||||
|
resolve_chrome_profile_base,
|
||||||
|
resolve_chrome_profile_dir,
|
||||||
|
wait_debug_port,
|
||||||
|
wait_devtools_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApaBrowserAdapter",
|
||||||
|
"REPO_ROOT",
|
||||||
|
"chrome_debug_port",
|
||||||
|
"launch_independent_chrome",
|
||||||
|
"resolve_chrome_profile_base",
|
||||||
|
"resolve_chrome_profile_dir",
|
||||||
|
"wait_debug_port",
|
||||||
|
"wait_devtools_port",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,879 @@
|
||||||
|
# Selenium Chrome 浏览器适配。合同和返利共用这一份。
|
||||||
|
#
|
||||||
|
# 通过 WebDriver 操控 Chrome。用户目录必须在仓库外,避免 list_dir 扫到数 GB 的 profile。
|
||||||
|
# 端口探测和 DevToolsActivePort 用短超时轮询,禁止无条件 sleep(1)。
|
||||||
|
# 返利用户 Chrome 的 junction / 端口由 rpa_rebate.apa_browser_adapter 覆盖。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
_FLOW_DEBUG_PORTS = {
|
||||||
|
"CONTRACT_CREATE": 9333,
|
||||||
|
"REBATE_FIND": 9335,
|
||||||
|
"default": 9334,
|
||||||
|
}
|
||||||
|
|
||||||
|
_IGNORE_SOURCE_JS = """
|
||||||
|
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
|
||||||
|
function compact(el) {
|
||||||
|
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
const hits = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => {
|
||||||
|
if (!vis(el)) return false;
|
||||||
|
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
|
||||||
|
return title === '货源选择';
|
||||||
|
});
|
||||||
|
for (const modal of hits) {
|
||||||
|
const btn = Array.from(modal.querySelectorAll('button')).find((el) => {
|
||||||
|
const t = compact(el).replace(/ /g, '');
|
||||||
|
return vis(el) && (t === '关闭' || t === '取消');
|
||||||
|
});
|
||||||
|
if (btn) { btn.click(); continue; }
|
||||||
|
const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close');
|
||||||
|
if (x) { x.click(); }
|
||||||
|
}
|
||||||
|
return hits.length;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# 判断 path 是否落在仓库目录树内。
|
||||||
|
def _is_inside_repo(path: Path, repo_root: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.resolve().relative_to(repo_root.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# 把 flow 名收成目录名安全的一段。空则 default。
|
||||||
|
def _sanitize_flow(flow: str) -> str:
|
||||||
|
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (flow or "default").strip())
|
||||||
|
return safe or "default"
|
||||||
|
|
||||||
|
|
||||||
|
# 解析 Chrome user-data 根目录,必须留在仓库外。
|
||||||
|
#
|
||||||
|
# 未指定时用仓库父目录下的 rpa-chrome-profile。RPA_CHROME_PROFILE 指到仓库内则改回这个默认。
|
||||||
|
def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# 按 flow 给出稳定的 Chrome 用户目录,后续班次复用登录态。
|
||||||
|
def resolve_chrome_profile_dir(
|
||||||
|
flow: str = "default",
|
||||||
|
requested: str | None = None,
|
||||||
|
*,
|
||||||
|
repo_root: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
|
base = resolve_chrome_profile_base(requested, repo_root=repo_root)
|
||||||
|
return (base / _sanitize_flow(flow)).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# 给出本 flow 的调试口。RPA_CHROME_DEBUG_PORT 优先;否则按班次表,未知回 9333。
|
||||||
|
def chrome_debug_port(flow: str = "default") -> int:
|
||||||
|
raw = (os.getenv("RPA_CHROME_DEBUG_PORT") or "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return _FLOW_DEBUG_PORTS.get(_sanitize_flow(flow), 9333)
|
||||||
|
|
||||||
|
|
||||||
|
# 探测 127.0.0.1:port 此刻能否连上。
|
||||||
|
def _port_open(port: int) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), timeout=0.4):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 从 profile 的 DevToolsActivePort 读出当前调试口,没有或坏文件返回 None。
|
||||||
|
def _read_devtools_port(profile_dir: Path) -> int | None:
|
||||||
|
marker = profile_dir / "DevToolsActivePort"
|
||||||
|
if not marker.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
first = marker.read_text(encoding="utf-8").splitlines()[0].strip()
|
||||||
|
port = int(first)
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
return port if port > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
# 轮询本机调试口直到可连上或超时。间隔 0.3 秒,不 sleep(1)。
|
||||||
|
def wait_debug_port(port: int, timeout: float = 20) -> bool:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() <= deadline:
|
||||||
|
if _port_open(port):
|
||||||
|
return True
|
||||||
|
time.sleep(0.3)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 轮询 DevToolsActivePort 直到读到正端口或超时。间隔 0.3 秒。
|
||||||
|
def wait_devtools_port(profile_dir: Path, timeout: float = 20) -> int | None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() <= deadline:
|
||||||
|
port = _read_devtools_port(profile_dir)
|
||||||
|
if port:
|
||||||
|
return port
|
||||||
|
time.sleep(0.3)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 构造 ChromeDriver Service。有 CHROMEDRIVER_PATH 用本地,否则 webdriver_manager 下载。
|
||||||
|
def _chrome_service():
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
|
||||||
|
chromedriver = os.getenv("CHROMEDRIVER_PATH")
|
||||||
|
if chromedriver and os.path.exists(chromedriver):
|
||||||
|
return Service(chromedriver)
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
|
||||||
|
return Service(ChromeDriverManager().install())
|
||||||
|
|
||||||
|
|
||||||
|
# 找到本机 chrome 可执行文件。CHROME_PATH 优先,找不到则失败。
|
||||||
|
def _chrome_binary() -> str:
|
||||||
|
raw = (os.getenv("CHROME_PATH") or "").strip()
|
||||||
|
local = Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "Application" / "chrome.exe"
|
||||||
|
candidates = [
|
||||||
|
raw,
|
||||||
|
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||||
|
str(local),
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if path and os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
raise RuntimeError("未找到 chrome.exe")
|
||||||
|
|
||||||
|
|
||||||
|
# 附着到已在调试口上的 Chrome,并跑一句脚本确认活着。
|
||||||
|
def _attach_chrome(service, port: int):
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
|
||||||
|
options = Options()
|
||||||
|
options.add_experimental_option("debuggerAddress", f"127.0.0.1:{port}")
|
||||||
|
driver = webdriver.Chrome(service=service, options=options)
|
||||||
|
driver.execute_script("return 1")
|
||||||
|
return driver
|
||||||
|
|
||||||
|
|
||||||
|
# 独立进程拉起 Chrome,不跟 Python / chromedriver 一起退出。
|
||||||
|
def _spawn_chrome(profile_dir: Path, port: int, *, restore_session: bool = False) -> None:
|
||||||
|
chrome = _chrome_binary()
|
||||||
|
cmd = [
|
||||||
|
chrome,
|
||||||
|
f"--remote-debugging-port={str(port)}",
|
||||||
|
"--user-data-dir=" + str(profile_dir),
|
||||||
|
"--remote-allow-origins=*",
|
||||||
|
"--start-maximized",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-popup-blocking",
|
||||||
|
]
|
||||||
|
if restore_session:
|
||||||
|
cmd.append("--restore-last-session")
|
||||||
|
kwargs: dict = {
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"stdout": subprocess.DEVNULL,
|
||||||
|
"stderr": subprocess.DEVNULL,
|
||||||
|
}
|
||||||
|
if os.name == "nt":
|
||||||
|
kwargs["close_fds"] = False
|
||||||
|
kwargs["creationflags"] = 0x00000008 | 0x00000200 | 0x01000000
|
||||||
|
subprocess.Popen(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# 强制结束 chrome.exe,给用户目录重开调试口腾位置。仅 Windows。
|
||||||
|
def _kill_chrome() -> None:
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
creation = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/IM", "chrome.exe", "/F"],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
creationflags=creation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 等到 profile 的 SingletonLock / lockfile 消失,或超时放弃。
|
||||||
|
def _wait_profile_unlocked(profile_dir: Path, timeout: float = 20) -> None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() <= deadline:
|
||||||
|
locked = (profile_dir / "SingletonLock").exists() or (profile_dir / "lockfile").exists()
|
||||||
|
if not locked:
|
||||||
|
return
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
|
||||||
|
# 独立拉起 Chrome 再附着调试口。端口和 DevTools 用轮询,不 sleep(1)。
|
||||||
|
#
|
||||||
|
# 口已开则直接附着。restore_session 时先结束旧进程、等 profile 解锁再拉起。
|
||||||
|
def launch_independent_chrome(service, profile_dir: Path, port: int, *, restore_session: bool = False):
|
||||||
|
if not _port_open(port):
|
||||||
|
if restore_session:
|
||||||
|
print(
|
||||||
|
f"Chrome 日常窗口无调试口,按同一用户目录重开 port={port} profile={profile_dir}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
_kill_chrome()
|
||||||
|
_wait_profile_unlocked(profile_dir)
|
||||||
|
_spawn_chrome(profile_dir, port, restore_session=restore_session)
|
||||||
|
if not wait_debug_port(port):
|
||||||
|
raise RuntimeError(f"Chrome 未在端口 {port} 起来")
|
||||||
|
wait_devtools_port(profile_dir, timeout=5)
|
||||||
|
return _attach_chrome(service, port)
|
||||||
|
|
||||||
|
|
||||||
|
# 用 chromedriver 新开带调试口的 Chrome,起来后再轮询调试口。
|
||||||
|
def _launch_chrome_selenium(service, profile_dir: Path, port: int):
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
|
||||||
|
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")
|
||||||
|
options.add_argument("--remote-allow-origins=*")
|
||||||
|
options.add_argument(f"--remote-debugging-port={port}")
|
||||||
|
options.add_argument("--user-data-dir=" + str(profile_dir))
|
||||||
|
driver = webdriver.Chrome(service=service, options=options)
|
||||||
|
wait_debug_port(port, timeout=5)
|
||||||
|
return driver
|
||||||
|
|
||||||
|
|
||||||
|
# Selenium Chrome 浏览器适配器,实现合同 / 返利页面流用的 BrowserAdapter 协议。
|
||||||
|
#
|
||||||
|
# 子类可覆盖 profile / 调试口 / 拉起方式。返利用户 Chrome 走这条扩展点。
|
||||||
|
class ApaBrowserAdapter:
|
||||||
|
# 记下起始 URL 和班次名。真正开浏览器推迟到第一次页面操作。
|
||||||
|
def __init__(self, url: str = "", *, flow: str = "default"):
|
||||||
|
self._url = url
|
||||||
|
self._flow = flow
|
||||||
|
self._driver = None
|
||||||
|
self._is_open = False
|
||||||
|
self._download_dir = ""
|
||||||
|
self._download_before: set[str] = set()
|
||||||
|
self._ignoring_source = False
|
||||||
|
self._hold_source_choice = False
|
||||||
|
|
||||||
|
# 本班次 Chrome 用户目录。默认按 flow 分子目录。
|
||||||
|
def _profile_dir(self) -> Path:
|
||||||
|
return resolve_chrome_profile_dir(self._flow)
|
||||||
|
|
||||||
|
# 本班次调试口。默认按 flow 查表。
|
||||||
|
def _debug_port(self) -> int:
|
||||||
|
return chrome_debug_port(self._flow)
|
||||||
|
|
||||||
|
# 是否按日常 Chrome 用户目录重开并恢复标签。默认否。
|
||||||
|
def _restore_session(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 若已有目标页签则切过去。默认不切。
|
||||||
|
def _focus_existing_tab(self, driver) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 新开 Chrome。默认 chromedriver;返利子类改成独立进程。
|
||||||
|
def _launch_driver(self, service, profile_dir: Path, port: int):
|
||||||
|
return _launch_chrome_selenium(service, profile_dir, port)
|
||||||
|
|
||||||
|
# 复用本班次已打开的 Chrome;没有就按稳定 profile 新开。
|
||||||
|
def _get_driver(self):
|
||||||
|
if self._driver is not None:
|
||||||
|
return self._driver
|
||||||
|
|
||||||
|
profile_dir = self._profile_dir()
|
||||||
|
if _is_inside_repo(profile_dir, REPO_ROOT):
|
||||||
|
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_dir}")
|
||||||
|
os.makedirs(profile_dir, exist_ok=True)
|
||||||
|
port = self._debug_port()
|
||||||
|
if not self._restore_session():
|
||||||
|
port = _read_devtools_port(profile_dir) or port
|
||||||
|
|
||||||
|
driver = None
|
||||||
|
if _port_open(port):
|
||||||
|
try:
|
||||||
|
driver = _attach_chrome(_chrome_service(), port)
|
||||||
|
print(f"Chrome 复用已有实例 flow={self._flow} port={port}", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Chrome 复用失败 port={port}:{exc}", flush=True)
|
||||||
|
driver = None
|
||||||
|
if driver is None:
|
||||||
|
launch_port = self._debug_port()
|
||||||
|
try:
|
||||||
|
driver = self._launch_driver(_chrome_service(), profile_dir, launch_port)
|
||||||
|
print(
|
||||||
|
f"Chrome 新开实例 flow={self._flow} port={launch_port} profile={profile_dir}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
retry_port = wait_devtools_port(profile_dir, timeout=2) or _read_devtools_port(profile_dir)
|
||||||
|
if retry_port and _port_open(retry_port):
|
||||||
|
driver = _attach_chrome(_chrome_service(), retry_port)
|
||||||
|
print(f"Chrome 锁定后改为复用 port={retry_port}", flush=True)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"打开 Chrome 失败:{exc}") from exc
|
||||||
|
|
||||||
|
driver.set_page_load_timeout(45)
|
||||||
|
driver.set_script_timeout(30)
|
||||||
|
self._driver = driver
|
||||||
|
return self._driver
|
||||||
|
|
||||||
|
# 保证浏览器已开。空白页才打开起始 URL;子类可先切到已有页签。
|
||||||
|
def _ensure_open(self):
|
||||||
|
if self._driver is None:
|
||||||
|
driver = self._get_driver()
|
||||||
|
href = ""
|
||||||
|
try:
|
||||||
|
href = str(driver.execute_script("return location.href") or "")
|
||||||
|
except Exception:
|
||||||
|
href = ""
|
||||||
|
blank = (not href) or href.startswith("chrome://") or href.startswith("about:")
|
||||||
|
if self._focus_existing_tab(driver):
|
||||||
|
print("Chrome 切到已打开的采购协议页", flush=True)
|
||||||
|
elif self._url and blank:
|
||||||
|
driver.get(self._url)
|
||||||
|
self._is_open = True
|
||||||
|
|
||||||
|
# 按 CSS / XPath / text= / label= / aria= / title= / :contains() 找第一个元素。
|
||||||
|
#
|
||||||
|
# timeout 保留给调用方语义,底层 find_element 本身不等待。
|
||||||
|
def _find(self, selector: str, timeout: int = 5):
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.common.exceptions import NoSuchElementException
|
||||||
|
import re
|
||||||
|
|
||||||
|
if selector.startswith("text="):
|
||||||
|
text_val = selector[5:].strip('"').strip("'")
|
||||||
|
xpath = f'//*[contains(text(),"{text_val}")]'
|
||||||
|
return self._driver.find_element(By.XPATH, xpath)
|
||||||
|
|
||||||
|
if selector.startswith("label="):
|
||||||
|
text_val = selector[6:].strip('"').strip("'")
|
||||||
|
xpath = (
|
||||||
|
f'//label[contains(text(),"{text_val}")]'
|
||||||
|
f"/following-sibling::*[1][self::input or self::select or self::textarea]"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return self._driver.find_element(By.XPATH, xpath)
|
||||||
|
except NoSuchElementException:
|
||||||
|
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
|
||||||
|
|
||||||
|
if selector.startswith("aria="):
|
||||||
|
text_val = selector[5:].strip('"').strip("'")
|
||||||
|
return self._driver.find_element(By.CSS_SELECTOR, f'[aria-label="{text_val}"]')
|
||||||
|
|
||||||
|
if selector.startswith("title="):
|
||||||
|
text_val = selector[6:].strip('"').strip("'")
|
||||||
|
return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if selector.startswith("//") or selector.startswith("/"):
|
||||||
|
return self._driver.find_element(By.XPATH, selector)
|
||||||
|
|
||||||
|
return self._driver.find_element(By.CSS_SELECTOR, selector)
|
||||||
|
|
||||||
|
# 打开指定 URL。eager 超时但文档已出来时继续,不把长轮询当失败。
|
||||||
|
def open_url(self, url: str) -> None:
|
||||||
|
self._url = url
|
||||||
|
driver = self._get_driver()
|
||||||
|
try:
|
||||||
|
driver.get(url)
|
||||||
|
except Exception as exc:
|
||||||
|
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
|
||||||
|
|
||||||
|
# 用原生 value setter 加 input/change/blur 注入,兼容 LUI/React 受控组件。
|
||||||
|
def _set_value_via_js(self, el, value: str) -> None:
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按选择器列表填第一个能写上的输入框。原生失败则改 JS 注入。
|
||||||
|
def fill_first(self, selectors: list, value: str) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
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)
|
||||||
|
self._ignore_source_bug()
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
# 真实鼠标点击,给只监听 mousedown 的 LUI 页签/工具栏用。
|
||||||
|
def click_native(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)
|
||||||
|
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)
|
||||||
|
self._ignore_source_bug()
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"click_native failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
# 按选择器列表 JS 点击第一个能点到的元素。
|
||||||
|
def click_first(self, selectors: list) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# 等到页面 body 出现指定文本,超时抛 TimeoutError。
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# 等到任一选择器能找到元素,超时抛 TimeoutError。
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# 任一选择器此刻能找到元素则 True。
|
||||||
|
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
|
||||||
|
|
||||||
|
# 切换到匹配到的 iframe。优先可见弹窗,避免切到上次留下的隐藏 iframe。
|
||||||
|
def switch_to_frame(self, selectors: list) -> None:
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# 切回主文档(顶层 frame)。
|
||||||
|
def switch_to_default_content(self) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
self._driver.switch_to.default_content()
|
||||||
|
|
||||||
|
# 看见「货源选择」bug 弹层就关,不参与任何选择。
|
||||||
|
def _ignore_source_bug(self) -> None:
|
||||||
|
if self._hold_source_choice or self._ignoring_source or self._driver is None:
|
||||||
|
return
|
||||||
|
self._ignoring_source = True
|
||||||
|
try:
|
||||||
|
n = self._driver.execute_script(_IGNORE_SOURCE_JS)
|
||||||
|
if n:
|
||||||
|
time.sleep(0.2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._ignoring_source = False
|
||||||
|
|
||||||
|
# 在当前页执行一段 JS 并返回结果。
|
||||||
|
def execute_script(self, script: str, *args):
|
||||||
|
self._ensure_open()
|
||||||
|
result = self._driver.execute_script(script, *args)
|
||||||
|
self._ignore_source_bug()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 选中 Landray xform fancy-select(.fs-wrap / .fs-option)。
|
||||||
|
def select_xform(self, name: str, value: str) -> None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 按选择器列表选中原生 select 的可见文本,失败再用 JS 点 option。
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
Select(el).select_by_visible_text(value)
|
||||||
|
time.sleep(0.3)
|
||||||
|
if self._selected_text_matches(el, value):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._select_via_js(el, value)
|
||||||
|
time.sleep(0.3)
|
||||||
|
if self._selected_text_matches(el, value):
|
||||||
|
return
|
||||||
|
raise RuntimeError(f"select_first 未选中目标项: {value}")
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"select_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
# 当前选中项的可见文本是否等于 value。
|
||||||
|
def _selected_text_matches(self, el, value: str) -> bool:
|
||||||
|
try:
|
||||||
|
script = (
|
||||||
|
"var el = arguments[0];"
|
||||||
|
"if (el.selectedIndex != null) {"
|
||||||
|
" var o = el.options[el.selectedIndex];"
|
||||||
|
" return o ? (o.textContent || o.text || '').trim() : '';"
|
||||||
|
"}"
|
||||||
|
"return (el.value || '').trim();"
|
||||||
|
)
|
||||||
|
selected = self._driver.execute_script(script, el)
|
||||||
|
return (selected or "").strip() == str(value).strip()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 用 JS 按可见文本或 value 选中 option 并派发 change。
|
||||||
|
def _select_via_js(self, el, value: str) -> None:
|
||||||
|
self._driver.execute_script(
|
||||||
|
"""
|
||||||
|
var el = arguments[0];
|
||||||
|
var value = arguments[1];
|
||||||
|
var options = el.options || [];
|
||||||
|
for (var i = 0; i < options.length; i++) {
|
||||||
|
var t = (options[i].textContent || options[i].text || '').trim();
|
||||||
|
if (t === value || options[i].value === value) {
|
||||||
|
el.selectedIndex = i;
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
el,
|
||||||
|
str(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按选择器列表给第一个 file 输入框送本地路径。
|
||||||
|
def upload_file_first(self, selectors: list, file_path: str) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
el.send_keys(file_path)
|
||||||
|
time.sleep(1)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"upload_file_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
# 把浏览器下载目录指到 path,并记下当前已有文件名以便 wait_for_download 认新文件。
|
||||||
|
def set_download_dir(self, path: str) -> None:
|
||||||
|
self._ensure_open()
|
||||||
|
dest = str(Path(path).expanduser().resolve())
|
||||||
|
Path(dest).mkdir(parents=True, exist_ok=True)
|
||||||
|
self._download_dir = dest
|
||||||
|
self._download_before = {p.name for p in Path(dest).iterdir()}
|
||||||
|
try:
|
||||||
|
self._driver.execute_cdp_cmd(
|
||||||
|
"Page.setDownloadBehavior",
|
||||||
|
{"behavior": "allow", "downloadPath": dest, "eventsEnabled": True},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._driver.execute_cdp_cmd(
|
||||||
|
"Browser.setDownloadBehavior",
|
||||||
|
{"behavior": "allow", "downloadPath": dest, "eventsEnabled": True},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"set_download_dir skipped: {exc}", flush=True)
|
||||||
|
|
||||||
|
# 等到下载目录出现新的 suffix 文件(忽略 .crdownload),返回最新那条路径。
|
||||||
|
def wait_for_download(self, suffix: str = ".xlsx", timeout_seconds: int = 20) -> str:
|
||||||
|
dest = Path(self._download_dir or "")
|
||||||
|
if not dest.is_dir():
|
||||||
|
raise RuntimeError("未设置下载目录")
|
||||||
|
before = set(self._download_before)
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
wanted = (suffix or ".xlsx").lower()
|
||||||
|
while time.time() <= deadline:
|
||||||
|
hits = [
|
||||||
|
p
|
||||||
|
for p in dest.iterdir()
|
||||||
|
if p.name not in before
|
||||||
|
and p.suffix.lower() == wanted
|
||||||
|
and not str(p).endswith(".crdownload")
|
||||||
|
and not Path(str(p) + ".crdownload").exists()
|
||||||
|
]
|
||||||
|
if hits:
|
||||||
|
return str(max(hits, key=lambda p: p.stat().st_mtime))
|
||||||
|
time.sleep(0.3)
|
||||||
|
raise RuntimeError(f"未下到 {wanted} 文件")
|
||||||
|
|
||||||
|
# 按选择器列表读第一个非空 text/value。
|
||||||
|
def read_first(self, selectors: list, value_name: str = "") -> str:
|
||||||
|
self._ensure_open()
|
||||||
|
last_error = None
|
||||||
|
for sel in selectors:
|
||||||
|
try:
|
||||||
|
el = self._find(sel)
|
||||||
|
result = el.text or el.get_attribute("value") or ""
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"read_first failed: {selectors}, last: {last_error}")
|
||||||
|
|
||||||
|
# 读当前页 body 可见文本,读不到返回空串。
|
||||||
|
def read_body_text(self) -> str:
|
||||||
|
self._ensure_open()
|
||||||
|
try:
|
||||||
|
return self._driver.find_element("tag name", "body").text
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 把当前页截到本包 accept.artifact_dir(flow) 下的 name.png,返回路径。
|
||||||
|
def screenshot(self, name: str) -> str:
|
||||||
|
from accept import artifact_dir
|
||||||
|
|
||||||
|
save_dir = artifact_dir(self._flow)
|
||||||
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = save_dir / f"{name}.png"
|
||||||
|
if self._driver:
|
||||||
|
self._driver.save_screenshot(str(path))
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
# 接受原生 alert/confirm 并返回其文本。没有弹窗则空串。
|
||||||
|
def accept_alert(self) -> str:
|
||||||
|
self._ensure_open()
|
||||||
|
try:
|
||||||
|
alert = self._driver.switch_to.alert
|
||||||
|
text = (alert.text or "").strip()
|
||||||
|
alert.accept()
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 关掉当前标签,在同一 Chrome 里新开一标签再打开 url。
|
||||||
|
def reopen_url(self, url: str) -> None:
|
||||||
|
driver = self._get_driver()
|
||||||
|
old = None
|
||||||
|
try:
|
||||||
|
old = driver.current_window_handle
|
||||||
|
driver.switch_to.new_window("tab")
|
||||||
|
new = driver.current_window_handle
|
||||||
|
if old and old != new:
|
||||||
|
try:
|
||||||
|
driver.switch_to.window(old)
|
||||||
|
driver.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
driver.switch_to.window(new)
|
||||||
|
except Exception:
|
||||||
|
handles = driver.window_handles
|
||||||
|
if handles:
|
||||||
|
driver.switch_to.window(handles[-1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.open_url(url)
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
选品已经把「哪个仓、加几件」写成表 2:`operate_type=1`、`status=1`、`push_status=0`、`update_user_name=系统管理员`。本班读这些行,登录 1 号药城 / 药帮忙,按条形码再搜一次,对上 `supplier_name`,改数量,加入购物车。同一 `omsId` 拆两仓、两平台要加齐。
|
选品已经把「哪个仓、加几件」写成表 2:`operate_type=1`、`status=1`、`push_status=0`、`update_user_name=系统管理员`。本班读这些行,登录 1 号药城 / 药帮忙,按条形码再搜一次,对上 `supplier_name`,改数量,加入购物车。同一 `omsId` 拆两仓、两平台要加齐。
|
||||||
|
|
||||||
成功不插新行,只把 `update_user_name` 改成 `加购班`。加不进去(登录挂、库存没了、中包装加不进、对不上仓)改该行 `status=2`,写失败原因。领取时先盖 `加购中`;中途崩溃停在这一戳,改回 `系统管理员` 再跑。
|
成功不插新行,只把 `update_user_name` 改成 `加购班`。加不进去(登录挂、库存没了、中包装加不进、对不上仓)改该行 `status=2`,写失败原因。领取时先盖 `加购中`;中途崩溃停在这一戳。`update_time` 超过 30 分钟的「加购中」下一班会再领;未过期的需改回 `系统管理员` 再跑。
|
||||||
|
|
||||||
不推订单中心,不清空购物车,不碰合同。表 2 没有 `sku_id`,加购必须再搜条形码。选完到加购之间库存可能变,已知。
|
不推订单中心,不清空购物车,不碰合同。表 2 没有 `sku_id`,加购必须再搜条形码。选完到加购之间库存可能变,已知。
|
||||||
|
|
||||||
|
|
@ -35,6 +35,48 @@ uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q
|
||||||
|
|
||||||
接口 2 仍捞全部 `push_status=0`。加购班跑完之前不要组装推送。
|
接口 2 仍捞全部 `push_status=0`。加购班跑完之前不要组装推送。
|
||||||
|
|
||||||
|
## 运维
|
||||||
|
|
||||||
|
### 怎么跑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rpa_cart
|
||||||
|
uv sync --python 3.12
|
||||||
|
uv run python main.py cart --limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
账号用 `rpa_selection/.env` 的 `YAOEX_*` / `YBM_*`,Oracle 空缺时补 `rpa_oms/.env`。连接从 `rpa_oracle` 池取,班次默认池大小 1。缺 `RPA_ORACLE_HOST` / 用户 / 密码时入口失败,文案「数据库不可用」。未捕获异常原文进 `rpa_shift_error_audit`,Windows 上先执行 `docs/ddl/rpa_oms_staging_tables.sql` 里这张表。
|
||||||
|
|
||||||
|
### 看哪张表哪几列
|
||||||
|
|
||||||
|
- 表 2 `rpa_cart_settlement_goods_data`:`operate_type=1`、`status`(1 成功,2 失败)、`push_status=0`、`update_user_name`(`系统管理员` 待领,`加购中` 处理中,`加购班` 本班已盖)、`fail_reason`、`update_time`、`oms_id`、`supplier_name`、`purchase_qty`
|
||||||
|
|
||||||
|
### 崩溃改回 SQL
|
||||||
|
|
||||||
|
停在「加购中」且未过 30 分钟租约:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE rpa_cart_settlement_goods_data
|
||||||
|
SET update_user_name = '系统管理员'
|
||||||
|
WHERE update_user_name = '加购中' AND fid = :fid;
|
||||||
|
```
|
||||||
|
|
||||||
|
过期的「加购中」下一班会再领,不必改。
|
||||||
|
|
||||||
|
### 原因列 vs 日志原文
|
||||||
|
|
||||||
|
表 2 `fail_reason`:登录挂写 `系统故障:LOGIN_FAILED`,查询抛错写 `系统故障:QUERY_FAILED`,加车抛错写 `系统故障:ADD_FAILED`。异常原文打本机日志,不进这一列。库存不足 / 中包装 / 对不上仓仍写业务中文。
|
||||||
|
|
||||||
|
### 点名命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rpa_cart
|
||||||
|
uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q
|
||||||
|
uv run pytest tests/test_cart_job.py::test_job_marks_out_of_stock -q
|
||||||
|
uv run pytest tests/test_cart_repo.py::test_from_env_without_user_raises -q
|
||||||
|
uv run pytest tests/test_cart_repo.py::test_claim_reclaims_expired_claiming_stamp tests/test_cart_repo.py::test_claim_skips_fresh_claiming_stamp -q
|
||||||
|
```
|
||||||
|
|
||||||
## 1 号药城支付落表
|
## 1 号药城支付落表
|
||||||
|
|
||||||
从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。
|
从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。
|
||||||
|
|
@ -44,9 +86,6 @@ cd rpa_cart
|
||||||
uv sync --python 3.12
|
uv sync --python 3.12
|
||||||
# 账号用 rpa_selection/.env 的 YAOEX_*,库用 RPA_ORACLE_*
|
# 账号用 rpa_selection/.env 的 YAOEX_*,库用 RPA_ORACLE_*
|
||||||
uv run python main.py
|
uv run python main.py
|
||||||
uv run python main.py --dry-run --xls /tmp/yaoex_order_export/orders.xls
|
|
||||||
uv run python main.py --days 0 # 仅当天(默认)
|
|
||||||
uv run python main.py --day 2026-09-30 --days 0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
过滤:只落 待发货 / 待收货 / 已完成。单价用折后单价,总价 `round(折后单价×数量, 4)`。`pay_order_no` 用商城订单号。`pay_order_dtl_id` 落库时自生成(UUID hex),不是平台数据,给订单中心防重传。
|
过滤:只落 待发货 / 待收货 / 已完成。单价用折后单价,总价 `round(折后单价×数量, 4)`。`pay_order_no` 用商城订单号。`pay_order_dtl_id` 落库时自生成(UUID hex),不是平台数据,给订单中心防重传。
|
||||||
|
|
@ -79,9 +118,7 @@ $env:RPA_ORACLE_CLIENT_LIB = "C:\oracle\instantclient"
|
||||||
|
|
||||||
uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q
|
uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q
|
||||||
uv run python main.py cart
|
uv run python main.py cart
|
||||||
# 国庆当天可能没单,用 9-30 有数
|
uv run python main.py
|
||||||
uv run python main.py --dry-run --day 2026-09-30 --days 0
|
|
||||||
uv run python main.py --day 2026-09-30 --days 0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
看控制台 `inserted / skipped_dup / unmatched`。同一订单号再跑应 `skipped_dup` 上升。
|
看控制台 `inserted / skipped_dup / unmatched`。同一订单号再跑应 `skipped_dup` 上升。
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""加车结果。HTTP 客户端把商城回包收成这三种。"""
|
# 加车结果。HTTP 客户端把商城回包收成这三种。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -8,12 +9,14 @@ KIND_BUSINESS = "business"
|
||||||
KIND_SYSTEM = "system"
|
KIND_SYSTEM = "system"
|
||||||
|
|
||||||
|
|
||||||
|
# 一次加车的结果:成功、业务失败(库存/中包装)或系统失败(登录、返回异常)。
|
||||||
@dataclass
|
@dataclass
|
||||||
class AddOutcome:
|
class AddOutcome:
|
||||||
kind: str
|
kind: str
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# 按商城回文区分成功、库存/中包装等业务失败,以及看不懂的系统失败。
|
||||||
def classify_mall_message(text: str) -> AddOutcome:
|
def classify_mall_message(text: str) -> AddOutcome:
|
||||||
msg = (text or "").strip()
|
msg = (text or "").strip()
|
||||||
if not msg:
|
if not msg:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
"""加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。成功盖章,失败改 status=2。"""
|
# 加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。
|
||||||
|
#
|
||||||
|
# 成功盖章,失败改 status=2。崩在「加购中」:超过租约可再领,未过期需改回「系统管理员」。
|
||||||
|
# 查询失败原因列只写 QUERY_FAILED / LOGIN_FAILED / ADD_FAILED。原文进审计表,会话结束后另写。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import OrderedDict
|
||||||
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
|
@ -10,13 +17,19 @@ from cart_add import ( # noqa: F401
|
||||||
KIND_SYSTEM,
|
KIND_SYSTEM,
|
||||||
AddOutcome,
|
AddOutcome,
|
||||||
)
|
)
|
||||||
from cart_match import CartOffer, match_offer, middle_pack, qty_fits_pack
|
from cart_match import CartOffer, match_offer
|
||||||
from cart_repo import CartRow, clip_reason
|
from cart_repo import CartRow, clip_reason
|
||||||
|
from rpa_oracle import ShiftError
|
||||||
|
|
||||||
Searcher = Callable[[str], list[CartOffer]]
|
Searcher = Callable[[str], list[CartOffer]]
|
||||||
Adder = Callable[[CartOffer, int], AddOutcome]
|
Adder = Callable[[CartOffer, int], AddOutcome]
|
||||||
|
CartReader = Callable[[str, str], int]
|
||||||
|
_RETRY_CODES = ("LOGIN_FAILED", "QUERY_FAILED")
|
||||||
|
logger = logging.getLogger("rpa_cart")
|
||||||
|
INCOMPLETE_MARK = "未加齐"
|
||||||
|
|
||||||
|
|
||||||
|
# 一个加购班次的领取和盖章计数。
|
||||||
@dataclass
|
@dataclass
|
||||||
class CartSummary:
|
class CartSummary:
|
||||||
claimed: int = 0
|
claimed: int = 0
|
||||||
|
|
@ -24,6 +37,14 @@ class CartSummary:
|
||||||
failed: int = 0
|
failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _session(repo):
|
||||||
|
"""有 session 就借一条,没有就空上下文。商城调用必须在这个块外面。"""
|
||||||
|
session = getattr(repo, "session", None)
|
||||||
|
if session is None:
|
||||||
|
return nullcontext()
|
||||||
|
return session()
|
||||||
|
|
||||||
|
|
||||||
def run_cart_batch(
|
def run_cart_batch(
|
||||||
repo,
|
repo,
|
||||||
*,
|
*,
|
||||||
|
|
@ -32,65 +53,143 @@ def run_cart_batch(
|
||||||
login_errors: dict[int, str],
|
login_errors: dict[int, str],
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
platform_types: list[int] | None = None,
|
platform_types: list[int] | None = None,
|
||||||
|
cart_readers: dict[int, CartReader] | None = None,
|
||||||
) -> CartSummary:
|
) -> CartSummary:
|
||||||
|
"""领取待加购行,按行搜仓、加车、盖章。
|
||||||
|
|
||||||
|
session 只包领取和每一组的盖章。搜索和加车在连接归还之后跑。一组盖完再处理下一组。
|
||||||
|
登录失败和查询失败把戳改回系统管理员。购物车里已有不少于本行的数量则直接成功,不加第二份。
|
||||||
|
limit 交给领取层。
|
||||||
|
"""
|
||||||
|
audits: list[ShiftError] = []
|
||||||
|
readers = cart_readers or {}
|
||||||
|
with _session(repo):
|
||||||
rows = repo.claim_pending(limit=limit, platform_types=platform_types)
|
rows = repo.claim_pending(limit=limit, platform_types=platform_types)
|
||||||
summary = CartSummary(claimed=len(rows))
|
summary = CartSummary(claimed=len(rows))
|
||||||
for row in rows:
|
for oms_id, group in _by_oms(rows).items():
|
||||||
reason = _add_one(row, searchers, adders, login_errors)
|
outcomes = [
|
||||||
|
(row, *_add_one(row, searchers, adders, login_errors, readers))
|
||||||
|
for row in group
|
||||||
|
]
|
||||||
|
incomplete = _is_incomplete(outcomes)
|
||||||
|
if incomplete:
|
||||||
|
logger.warning("加购未加齐 oms_id=%s", oms_id)
|
||||||
|
with _session(repo):
|
||||||
|
for row, reason, code, text in outcomes:
|
||||||
if reason is None:
|
if reason is None:
|
||||||
repo.mark_success(row.fid)
|
repo.mark_success(row.fid)
|
||||||
summary.added += 1
|
summary.added += 1
|
||||||
|
continue
|
||||||
|
if incomplete:
|
||||||
|
reason = _incomplete_reason(reason)
|
||||||
|
if _retryable(code, reason):
|
||||||
|
repo.mark_release(row.fid, reason)
|
||||||
else:
|
else:
|
||||||
repo.mark_fail(row.fid, reason)
|
repo.mark_fail(row.fid, reason)
|
||||||
summary.failed += 1
|
summary.failed += 1
|
||||||
|
if code and text:
|
||||||
|
audits.append(ShiftError("加购", str(row.fid), code, text))
|
||||||
|
repo.save_shift_errors(audits)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# 按 oms_id 保序分组。同组先加完再盖章,才能看出有没有加齐。
|
||||||
|
def _by_oms(rows: list[CartRow]) -> OrderedDict[int, list[CartRow]]:
|
||||||
|
groups: OrderedDict[int, list[CartRow]] = OrderedDict()
|
||||||
|
for row in rows:
|
||||||
|
groups.setdefault(int(row.oms_id), []).append(row)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
# 同组里既有成功又有失败,才算这单没加齐。
|
||||||
|
def _is_incomplete(outcomes: list[tuple]) -> bool:
|
||||||
|
flags = [reason is None for _row, reason, _code, _text in outcomes]
|
||||||
|
return any(flags) and not all(flags)
|
||||||
|
|
||||||
|
|
||||||
|
# 失败原因前加上未加齐,已带前缀的不再套一层。
|
||||||
|
def _incomplete_reason(reason: str) -> str:
|
||||||
|
text = (reason or "").strip()
|
||||||
|
if text.startswith(INCOMPLETE_MARK):
|
||||||
|
return text
|
||||||
|
return f"{INCOMPLETE_MARK}:{text}" if text else INCOMPLETE_MARK
|
||||||
|
|
||||||
|
|
||||||
|
def _retryable(code: str | None, reason: str | None) -> bool:
|
||||||
|
"""登录失败和查询失败放回队列。加车失败、对不上仓、库存不足不放。"""
|
||||||
|
blob = f"{code or ''} {reason or ''}"
|
||||||
|
return any(token in blob for token in _RETRY_CODES)
|
||||||
|
|
||||||
|
|
||||||
def _add_one(
|
def _add_one(
|
||||||
row: CartRow,
|
row: CartRow,
|
||||||
searchers: dict[int, Searcher],
|
searchers: dict[int, Searcher],
|
||||||
adders: dict[int, Adder],
|
adders: dict[int, Adder],
|
||||||
login_errors: dict[int, str],
|
login_errors: dict[int, str],
|
||||||
) -> str | None:
|
cart_readers: dict[int, CartReader],
|
||||||
|
) -> tuple[str | None, str | None, str | None]:
|
||||||
|
"""对一行查购物车、搜仓并加车。
|
||||||
|
|
||||||
|
返回 (失败原因, 审计短码, 原文)。成功三项都是 None。
|
||||||
|
购物车里该条形码和供应商的数量已经不少于本行时直接成功,多出来的数量不加第二份。
|
||||||
|
业务原因(对不上仓、库存、商城回的业务失败)不进审计。没有原文也不进。
|
||||||
|
"""
|
||||||
platform = row.platform_type
|
platform = row.platform_type
|
||||||
if platform in login_errors:
|
if platform in login_errors:
|
||||||
return _system(login_errors[platform])
|
text = (login_errors[platform] or "").strip()
|
||||||
|
logger.warning("加购登录失败 platform=%s: %s", platform, login_errors[platform])
|
||||||
|
return _system("LOGIN_FAILED"), ("LOGIN_FAILED" if text else None), (text or None)
|
||||||
searcher = searchers.get(platform)
|
searcher = searchers.get(platform)
|
||||||
if searcher is None:
|
if searcher is None:
|
||||||
return _system("未登录该平台")
|
return _system("LOGIN_FAILED"), None, None
|
||||||
adder = adders.get(platform)
|
adder = adders.get(platform)
|
||||||
if adder is None:
|
if adder is None:
|
||||||
return _system("未配置加车")
|
return _system("ADD_FAILED"), None, None
|
||||||
bar_code = (row.bar_code or "").strip()
|
bar_code = (row.bar_code or "").strip()
|
||||||
if not bar_code:
|
if not bar_code:
|
||||||
return "缺少条形码"
|
return "缺少条形码", None, None
|
||||||
qty = int(row.purchase_qty)
|
qty = int(row.purchase_qty)
|
||||||
if qty <= 0:
|
if qty <= 0:
|
||||||
return "采购数量无效"
|
return "采购数量无效", None, None
|
||||||
|
reader = cart_readers.get(platform)
|
||||||
|
if reader is not None:
|
||||||
|
try:
|
||||||
|
already = int(reader(bar_code, row.supplier_name) or 0)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("加购查购物车失败 platform=%s bar_code=%s", platform, bar_code)
|
||||||
|
return _system("QUERY_FAILED"), "QUERY_FAILED", _text(exc)
|
||||||
|
if already >= qty:
|
||||||
|
return None, None, None
|
||||||
try:
|
try:
|
||||||
offers = searcher(bar_code)
|
offers = searcher(bar_code)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _system(f"查询失败:{exc}")
|
logger.exception("加购查询失败 platform=%s bar_code=%s", platform, bar_code)
|
||||||
|
return _system("QUERY_FAILED"), "QUERY_FAILED", _text(exc)
|
||||||
offer = match_offer(offers, row.supplier_name)
|
offer = match_offer(offers, row.supplier_name)
|
||||||
if offer is None:
|
if offer is None:
|
||||||
return "未找到对应仓"
|
return "未找到对应仓", None, None
|
||||||
if offer.stock is not None and offer.stock < qty:
|
if offer.stock is not None and offer.stock < qty:
|
||||||
return "库存不足"
|
return "库存不足", None, None
|
||||||
pack = middle_pack(offer)
|
|
||||||
if not qty_fits_pack(qty, pack):
|
|
||||||
return f"中包装加不进:数量须为{pack}的倍数"
|
|
||||||
try:
|
try:
|
||||||
outcome = adder(offer, qty)
|
outcome = adder(offer, qty)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _system(f"加购失败:{exc}")
|
logger.exception("加购加车失败 platform=%s fid=%s", platform, row.fid)
|
||||||
|
return _system("ADD_FAILED"), "ADD_FAILED", _text(exc)
|
||||||
if outcome.kind == KIND_OK:
|
if outcome.kind == KIND_OK:
|
||||||
return None
|
return None, None, None
|
||||||
reason = (outcome.reason or "加购失败").strip()
|
reason = (outcome.reason or "加购失败").strip()
|
||||||
if outcome.kind == KIND_SYSTEM:
|
if outcome.kind == KIND_SYSTEM:
|
||||||
return _system(reason)
|
return _system(reason), None, None
|
||||||
return clip_reason(reason)
|
return clip_reason(reason), None, None
|
||||||
|
|
||||||
|
|
||||||
|
# 取出异常原文。空串不当审计正文。
|
||||||
|
def _text(exc: BaseException) -> str | None:
|
||||||
|
text = str(exc).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
# 把系统故障收成「系统故障:短码」并截断。已带前缀的不再套一层。
|
||||||
def _system(detail: str) -> str:
|
def _system(detail: str) -> str:
|
||||||
text = (detail or "未知错误").strip()
|
text = (detail or "未知错误").strip()
|
||||||
if text.startswith("系统故障"):
|
if text.startswith("系统故障"):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
"""按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。"""
|
# 按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
SELF_OPERATED_PREFIX = "[自营]"
|
SELF_OPERATED_PREFIX = "[自营]"
|
||||||
PACK_KEYS = ("middlePackNum", "midPackQty", "packNum", "packQty")
|
|
||||||
|
|
||||||
|
|
||||||
|
# 再搜到的一条报价:商家全称、库存、价格。加车前靠它对上表 2 的供应商。
|
||||||
@dataclass
|
@dataclass
|
||||||
class CartOffer:
|
class CartOffer:
|
||||||
sku_id: str
|
sku_id: str
|
||||||
|
|
@ -18,6 +19,7 @@ class CartOffer:
|
||||||
extra: dict[str, Any] = field(default_factory=dict)
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# 去掉「[自营]」前缀和多余空白,用来对商家全称。
|
||||||
def normalize_supplier(raw: str) -> str:
|
def normalize_supplier(raw: str) -> str:
|
||||||
text = (raw or "").replace("\u3000", " ").strip()
|
text = (raw or "").replace("\u3000", " ").strip()
|
||||||
if text.startswith(SELF_OPERATED_PREFIX):
|
if text.startswith(SELF_OPERATED_PREFIX):
|
||||||
|
|
@ -25,6 +27,7 @@ def normalize_supplier(raw: str) -> str:
|
||||||
return " ".join(text.split())
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
# 条形码结果里对上商家全称。同仓留低价,价格相同留库存多的。对不上返回 None。
|
||||||
def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None:
|
def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None:
|
||||||
target = normalize_supplier(supplier_name)
|
target = normalize_supplier(supplier_name)
|
||||||
if not target:
|
if not target:
|
||||||
|
|
@ -40,23 +43,7 @@ def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None
|
||||||
return min(matched, key=_offer_rank)
|
return min(matched, key=_offer_rank)
|
||||||
|
|
||||||
|
|
||||||
def middle_pack(offer: CartOffer) -> int | None:
|
# 同仓排序:有价格的优先,低价优先,同价库存多的优先。
|
||||||
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
return None
|
|
||||||
for key in PACK_KEYS:
|
|
||||||
value = _positive_int(raw.get(key))
|
|
||||||
if value is not None:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def qty_fits_pack(qty: int, pack: int | None) -> bool:
|
|
||||||
if pack is None or pack <= 1:
|
|
||||||
return True
|
|
||||||
return qty % pack == 0
|
|
||||||
|
|
||||||
|
|
||||||
def _offer_rank(offer: CartOffer) -> tuple[int, float, float]:
|
def _offer_rank(offer: CartOffer) -> tuple[int, float, float]:
|
||||||
price = offer.price
|
price = offer.price
|
||||||
stock = offer.stock
|
stock = offer.stock
|
||||||
|
|
@ -65,15 +52,3 @@ def _offer_rank(offer: CartOffer) -> tuple[int, float, float]:
|
||||||
float(price) if price is not None else 0.0,
|
float(price) if price is not None else 0.0,
|
||||||
-float(stock) if stock is not None else 0.0,
|
-float(stock) if stock is not None else 0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _positive_int(value: object) -> int | None:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
number = int(float(str(value)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if number <= 0:
|
|
||||||
return None
|
|
||||||
return number
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
"""表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。"""
|
# 表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。
|
||||||
|
#
|
||||||
|
# 领取时盖「加购中」。进程中途退出时行停在这一戳,超过租约(update_time 30 分钟)才能再领。
|
||||||
|
# 未过期的「加购中」需改回「系统管理员」再跑。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from db import RepoUnavailable, connect_from_env, oracle_dsn
|
from rpa_oracle import RepoUnavailable, job_pool, update_by_fid_chunks
|
||||||
|
from rpa_oracle.audit import ShiftError, save_shift_errors as write_shift_errors
|
||||||
|
|
||||||
OPERATE_TYPE_ADD_CART = 1
|
OPERATE_TYPE_ADD_CART = 1
|
||||||
STATUS_OK = 1
|
STATUS_OK = 1
|
||||||
|
|
@ -15,16 +21,13 @@ STAMP_SELECTION = "系统管理员"
|
||||||
STAMP_CLAIMING = "加购中"
|
STAMP_CLAIMING = "加购中"
|
||||||
STAMP_DONE = "加购班"
|
STAMP_DONE = "加购班"
|
||||||
FAIL_REASON_MAX = 500
|
FAIL_REASON_MAX = 500
|
||||||
|
LEASE_MINUTES = 30
|
||||||
|
|
||||||
_CLAIM_SQL = """
|
_CLAIM_SQL = """
|
||||||
UPDATE rpa_cart_settlement_goods_data
|
UPDATE rpa_cart_settlement_goods_data
|
||||||
SET update_user_name = :stamp,
|
SET update_user_name = :stamp,
|
||||||
update_time = SYSDATE
|
update_time = SYSDATE
|
||||||
WHERE fid = :fid
|
WHERE fid IN ({placeholders})
|
||||||
AND operate_type = 1
|
|
||||||
AND status = 1
|
|
||||||
AND push_status = 0
|
|
||||||
AND NVL(update_user_name, :selection_stamp) = :selection_stamp
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_SUCCESS_SQL = """
|
_SUCCESS_SQL = """
|
||||||
|
|
@ -46,7 +49,17 @@ WHERE fid = :fid
|
||||||
AND update_user_name = :claim_stamp
|
AND update_user_name = :claim_stamp
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_RELEASE_SQL = """
|
||||||
|
UPDATE rpa_cart_settlement_goods_data
|
||||||
|
SET update_user_name = :stamp,
|
||||||
|
fail_reason = :fail_reason,
|
||||||
|
update_time = SYSDATE
|
||||||
|
WHERE fid = :fid
|
||||||
|
AND update_user_name = :claim_stamp
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# 表 2 一行待加购或处理中的结算货品。
|
||||||
@dataclass
|
@dataclass
|
||||||
class CartRow:
|
class CartRow:
|
||||||
fid: int
|
fid: int
|
||||||
|
|
@ -62,6 +75,7 @@ class CartRow:
|
||||||
operate_type: int = OPERATE_TYPE_ADD_CART
|
operate_type: int = OPERATE_TYPE_ADD_CART
|
||||||
|
|
||||||
|
|
||||||
|
# 把失败原因截到 FAIL_REASON_MAX 字,超长末尾加省略号。
|
||||||
def clip_reason(text: str) -> str:
|
def clip_reason(text: str) -> str:
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if len(text) <= FAIL_REASON_MAX:
|
if len(text) <= FAIL_REASON_MAX:
|
||||||
|
|
@ -69,16 +83,26 @@ def clip_reason(text: str) -> str:
|
||||||
return text[: FAIL_REASON_MAX - 1] + "…"
|
return text[: FAIL_REASON_MAX - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
def _is_pending(row: dict[str, Any]) -> bool:
|
# 判断内存行是否可领:系统管理员,或加购中且 update_time 已过租约。
|
||||||
|
def _is_pending(row: dict[str, Any], now: datetime) -> bool:
|
||||||
stamp = row.get("update_user_name") or STAMP_SELECTION
|
stamp = row.get("update_user_name") or STAMP_SELECTION
|
||||||
return (
|
if (
|
||||||
int(row.get("operate_type") or 0) == OPERATE_TYPE_ADD_CART
|
int(row.get("operate_type") or 0) != OPERATE_TYPE_ADD_CART
|
||||||
and int(row.get("status") or 0) == STATUS_OK
|
or int(row.get("status") or 0) != STATUS_OK
|
||||||
and int(row.get("push_status") or 0) == PUSH_NOT_SENT
|
or int(row.get("push_status") or 0) != PUSH_NOT_SENT
|
||||||
and stamp == STAMP_SELECTION
|
):
|
||||||
)
|
return False
|
||||||
|
if stamp == STAMP_SELECTION:
|
||||||
|
return True
|
||||||
|
if stamp == STAMP_CLAIMING:
|
||||||
|
claimed_at = row.get("update_time")
|
||||||
|
if claimed_at is None:
|
||||||
|
return False
|
||||||
|
return now - claimed_at >= timedelta(minutes=LEASE_MINUTES)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 把字典收成 CartRow。缺数量当 0。
|
||||||
def _row_from_dict(data: dict[str, Any]) -> CartRow:
|
def _row_from_dict(data: dict[str, Any]) -> CartRow:
|
||||||
qty = data.get("purchase_qty")
|
qty = data.get("purchase_qty")
|
||||||
return CartRow(
|
return CartRow(
|
||||||
|
|
@ -96,130 +120,34 @@ def _row_from_dict(data: dict[str, Any]) -> CartRow:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MemoryCartRepo:
|
# 回滚当前事务。回滚本身失败则忽略。
|
||||||
def __init__(self, rows: list[dict[str, Any]]):
|
def _rollback(conn) -> None:
|
||||||
self.rows: dict[int, dict[str, Any]] = {int(row["fid"]): dict(row) for row in rows}
|
|
||||||
|
|
||||||
def claim_pending(
|
|
||||||
self, limit: int | None = None, platform_types: list[int] | None = None
|
|
||||||
) -> list[CartRow]:
|
|
||||||
pending = sorted(
|
|
||||||
(row for row in self.rows.values() if _is_pending(row)),
|
|
||||||
key=lambda row: (int(row["oms_id"]), int(row["fid"])),
|
|
||||||
)
|
|
||||||
if platform_types is not None:
|
|
||||||
allowed = set(platform_types)
|
|
||||||
pending = [row for row in pending if int(row["platform_type"]) in allowed]
|
|
||||||
if limit is not None:
|
|
||||||
pending = pending[:limit]
|
|
||||||
claimed: list[CartRow] = []
|
|
||||||
for row in pending:
|
|
||||||
row["update_user_name"] = STAMP_CLAIMING
|
|
||||||
claimed.append(_row_from_dict(row))
|
|
||||||
return claimed
|
|
||||||
|
|
||||||
def mark_success(self, fid: int) -> None:
|
|
||||||
row = self.rows[fid]
|
|
||||||
row["update_user_name"] = STAMP_DONE
|
|
||||||
row["fail_reason"] = None
|
|
||||||
|
|
||||||
def mark_fail(self, fid: int, reason: str) -> None:
|
|
||||||
row = self.rows[fid]
|
|
||||||
row["status"] = STATUS_FAIL
|
|
||||||
row["fail_reason"] = clip_reason(reason)
|
|
||||||
row["update_user_name"] = STAMP_DONE
|
|
||||||
|
|
||||||
|
|
||||||
class OracleCartRepo:
|
|
||||||
def __init__(self, connect: Callable):
|
|
||||||
self._connect = connect
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_env(cls) -> "OracleCartRepo":
|
|
||||||
oracle_dsn()
|
|
||||||
return cls(connect=connect_from_env)
|
|
||||||
|
|
||||||
def claim_pending(
|
|
||||||
self, limit: int | None = None, platform_types: list[int] | None = None
|
|
||||||
) -> list[CartRow]:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
sql, params = _pending_sql(limit=limit, platform_types=platform_types)
|
|
||||||
cur.execute(sql, params)
|
|
||||||
fetched = [_row_from_cursor(cur, rec) for rec in cur.fetchall()]
|
|
||||||
claimed: list[CartRow] = []
|
|
||||||
for row in fetched:
|
|
||||||
cur.execute(
|
|
||||||
_CLAIM_SQL,
|
|
||||||
{
|
|
||||||
"fid": row.fid,
|
|
||||||
"stamp": STAMP_CLAIMING,
|
|
||||||
"selection_stamp": STAMP_SELECTION,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if cur.rowcount == 1:
|
|
||||||
row.update_user_name = STAMP_CLAIMING
|
|
||||||
claimed.append(row)
|
|
||||||
conn.commit()
|
|
||||||
return claimed
|
|
||||||
except Exception as exc:
|
|
||||||
try:
|
try:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def mark_success(self, fid: int) -> None:
|
|
||||||
self._execute(
|
|
||||||
_SUCCESS_SQL,
|
|
||||||
{"fid": int(fid), "stamp": STAMP_DONE, "claim_stamp": STAMP_CLAIMING},
|
|
||||||
)
|
|
||||||
|
|
||||||
def mark_fail(self, fid: int, reason: str) -> None:
|
|
||||||
self._execute(
|
|
||||||
_FAIL_SQL,
|
|
||||||
{
|
|
||||||
"fid": int(fid),
|
|
||||||
"status": STATUS_FAIL,
|
|
||||||
"fail_reason": clip_reason(reason),
|
|
||||||
"stamp": STAMP_DONE,
|
|
||||||
"claim_stamp": STAMP_CLAIMING,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _execute(self, sql: str, params: dict[str, Any]) -> None:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(sql, params)
|
|
||||||
conn.commit()
|
|
||||||
except Exception as exc:
|
|
||||||
try:
|
|
||||||
conn.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _row_from_cursor(cur, rec) -> CartRow:
|
# 拼领取 SELECT:系统管理员或过期加购中,末尾 FOR UPDATE SKIP LOCKED。
|
||||||
cols = [item[0].lower() for item in cur.description]
|
def _pending_select_sql(
|
||||||
return _row_from_dict(dict(zip(cols, rec)))
|
|
||||||
|
|
||||||
|
|
||||||
def _pending_sql(
|
|
||||||
*, limit: int | None, platform_types: list[int] | None
|
*, limit: int | None, platform_types: list[int] | None
|
||||||
) -> tuple[str, dict[str, Any]]:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
params: dict[str, Any] = {"selection_stamp": STAMP_SELECTION}
|
params: dict[str, Any] = {
|
||||||
|
"selection_stamp": STAMP_SELECTION,
|
||||||
|
"claim_stamp": STAMP_CLAIMING,
|
||||||
|
"lease_minutes": LEASE_MINUTES,
|
||||||
|
}
|
||||||
filters = [
|
filters = [
|
||||||
"operate_type = 1",
|
"operate_type = 1",
|
||||||
"status = 1",
|
"status = 1",
|
||||||
"push_status = 0",
|
"push_status = 0",
|
||||||
"NVL(update_user_name, :selection_stamp) = :selection_stamp",
|
"""(
|
||||||
|
NVL(update_user_name, :selection_stamp) = :selection_stamp
|
||||||
|
OR (
|
||||||
|
update_user_name = :claim_stamp
|
||||||
|
AND update_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE')
|
||||||
|
)
|
||||||
|
)""",
|
||||||
]
|
]
|
||||||
if platform_types is not None:
|
if platform_types is not None:
|
||||||
if not platform_types:
|
if not platform_types:
|
||||||
|
|
@ -242,4 +170,201 @@ ORDER BY oms_id, fid
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
params["limit"] = int(limit)
|
params["limit"] = int(limit)
|
||||||
sql = f"SELECT * FROM (\n{sql}\n) WHERE ROWNUM <= :limit"
|
sql = f"SELECT * FROM (\n{sql}\n) WHERE ROWNUM <= :limit"
|
||||||
return sql, params
|
return sql + "\nFOR UPDATE SKIP LOCKED", params
|
||||||
|
|
||||||
|
|
||||||
|
# 加购班次的内存仓库,测规则和班次编排,不测 SQL。
|
||||||
|
class MemoryCartRepo:
|
||||||
|
# 用给定的表 2 行初始化。同一 fid 后写覆盖先写。
|
||||||
|
def __init__(self, rows: list[dict[str, Any]]):
|
||||||
|
self.rows: dict[int, dict[str, Any]] = {int(row["fid"]): dict(row) for row in rows}
|
||||||
|
self.shift_errors: list[ShiftError] = []
|
||||||
|
|
||||||
|
# 领取待加购任务,把戳从系统管理员或过期加购中改成加购中。
|
||||||
|
#
|
||||||
|
# 未过租约的加购中不领。limit 为空表示不限制条数。now 只给单测钉住时钟。
|
||||||
|
def claim_pending(
|
||||||
|
self,
|
||||||
|
limit: int | None = None,
|
||||||
|
platform_types: list[int] | None = None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> list[CartRow]:
|
||||||
|
clock = now or datetime.now()
|
||||||
|
pending = sorted(
|
||||||
|
(row for row in self.rows.values() if _is_pending(row, clock)),
|
||||||
|
key=lambda row: (int(row["oms_id"]), int(row["fid"])),
|
||||||
|
)
|
||||||
|
if platform_types is not None:
|
||||||
|
allowed = set(platform_types)
|
||||||
|
pending = [row for row in pending if int(row["platform_type"]) in allowed]
|
||||||
|
if limit is not None:
|
||||||
|
pending = pending[:limit]
|
||||||
|
claimed: list[CartRow] = []
|
||||||
|
for row in pending:
|
||||||
|
row["update_user_name"] = STAMP_CLAIMING
|
||||||
|
row["update_time"] = clock
|
||||||
|
claimed.append(_row_from_dict(row))
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
# 成功盖加购班,清失败原因,不改 status。
|
||||||
|
def mark_success(self, fid: int) -> None:
|
||||||
|
row = self.rows[fid]
|
||||||
|
row["update_user_name"] = STAMP_DONE
|
||||||
|
row["fail_reason"] = None
|
||||||
|
|
||||||
|
# 失败改 status=2,写下截断后的原因,戳改成加购班。
|
||||||
|
def mark_fail(self, fid: int, reason: str) -> None:
|
||||||
|
row = self.rows[fid]
|
||||||
|
row["status"] = STATUS_FAIL
|
||||||
|
row["fail_reason"] = clip_reason(reason)
|
||||||
|
row["update_user_name"] = STAMP_DONE
|
||||||
|
|
||||||
|
def mark_release(self, fid: int, reason: str) -> None:
|
||||||
|
"""登录或查询失败放回待领。戳改回系统管理员,status 保持 1。"""
|
||||||
|
row = self.rows[fid]
|
||||||
|
row["update_user_name"] = STAMP_SELECTION
|
||||||
|
row["fail_reason"] = clip_reason(reason)
|
||||||
|
|
||||||
|
# 记下未捕获异常原文。不改表 2。
|
||||||
|
def save_shift_errors(self, items: list[ShiftError]) -> None:
|
||||||
|
self.shift_errors.extend(items)
|
||||||
|
|
||||||
|
|
||||||
|
# 加购班次连 Oracle 的仓库,管表 2 领取和盖章。
|
||||||
|
#
|
||||||
|
# 连接从 rpa_oracle 连接池取。领取戳是加购中。
|
||||||
|
# 进程退出后行停在加购中,超过租约才能再领。
|
||||||
|
class OracleCartRepo:
|
||||||
|
# 挂上连接池。方法里按次 acquire,班次 session 内复用一条。
|
||||||
|
def __init__(self, pool):
|
||||||
|
self._pool = pool
|
||||||
|
self._held = None
|
||||||
|
|
||||||
|
# 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "OracleCartRepo":
|
||||||
|
return cls(pool=job_pool())
|
||||||
|
|
||||||
|
# 未捕获异常原文进共用审计表。独立事务,失败只记日志。
|
||||||
|
def save_shift_errors(self, items: list[ShiftError]) -> None:
|
||||||
|
write_shift_errors(self._pool, items)
|
||||||
|
|
||||||
|
# 班次内借一条连接,领取和盖章共用。每笔盖章单独提交。
|
||||||
|
@contextmanager
|
||||||
|
def session(self):
|
||||||
|
if self._held is not None:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
with self._pool.acquire() as conn:
|
||||||
|
self._held = conn
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._held = None
|
||||||
|
|
||||||
|
# 有 session 时交出已借连接,否则从池里新借一条。
|
||||||
|
def _acquire(self):
|
||||||
|
if self._held is not None:
|
||||||
|
|
||||||
|
# 交出班次已借的那条连接,退出时不归还。
|
||||||
|
@contextmanager
|
||||||
|
def _held():
|
||||||
|
yield self._held
|
||||||
|
|
||||||
|
return _held()
|
||||||
|
return self._pool.acquire()
|
||||||
|
|
||||||
|
def claim_pending(
|
||||||
|
self, limit: int | None = None, platform_types: list[int] | None = None
|
||||||
|
) -> list[CartRow]:
|
||||||
|
"""领取待加购任务,把表 2 的戳从系统管理员改成加购中。
|
||||||
|
|
||||||
|
使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。
|
||||||
|
更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。
|
||||||
|
进程退出后行停在加购中,超过租约(update_time)才能再领。
|
||||||
|
limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。
|
||||||
|
"""
|
||||||
|
sql, params = _pending_select_sql(limit=limit, platform_types=platform_types)
|
||||||
|
try:
|
||||||
|
with self._acquire() as conn:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
fetched = [_row_from_cursor(cur, rec) for rec in cur.fetchall()]
|
||||||
|
if fetched:
|
||||||
|
update_by_fid_chunks(
|
||||||
|
cur,
|
||||||
|
_CLAIM_SQL,
|
||||||
|
[row.fid for row in fetched],
|
||||||
|
{"stamp": STAMP_CLAIMING},
|
||||||
|
)
|
||||||
|
for row in fetched:
|
||||||
|
row.update_user_name = STAMP_CLAIMING
|
||||||
|
conn.commit()
|
||||||
|
return fetched
|
||||||
|
except Exception as exc:
|
||||||
|
_rollback(conn)
|
||||||
|
if isinstance(exc, RepoUnavailable):
|
||||||
|
raise
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
except RepoUnavailable:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
# 成功盖加购班。只改仍停在加购中的那一行。
|
||||||
|
def mark_success(self, fid: int) -> None:
|
||||||
|
self._execute(
|
||||||
|
_SUCCESS_SQL,
|
||||||
|
{"fid": int(fid), "stamp": STAMP_DONE, "claim_stamp": STAMP_CLAIMING},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 失败改 status=2 并写原因。只改仍停在加购中的那一行。
|
||||||
|
def mark_fail(self, fid: int, reason: str) -> None:
|
||||||
|
self._execute(
|
||||||
|
_FAIL_SQL,
|
||||||
|
{
|
||||||
|
"fid": int(fid),
|
||||||
|
"status": STATUS_FAIL,
|
||||||
|
"fail_reason": clip_reason(reason),
|
||||||
|
"stamp": STAMP_DONE,
|
||||||
|
"claim_stamp": STAMP_CLAIMING,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_release(self, fid: int, reason: str) -> None:
|
||||||
|
"""登录或查询失败放回待领。只改仍停在加购中的那一行,status 不动。"""
|
||||||
|
self._execute(
|
||||||
|
_RELEASE_SQL,
|
||||||
|
{
|
||||||
|
"fid": int(fid),
|
||||||
|
"fail_reason": clip_reason(reason),
|
||||||
|
"stamp": STAMP_SELECTION,
|
||||||
|
"claim_stamp": STAMP_CLAIMING,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 在当前连接上执行一条 UPDATE 并提交。库故障对外「数据库不可用」。
|
||||||
|
def _execute(self, sql: str, params: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
with self._acquire() as conn:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
_rollback(conn)
|
||||||
|
if isinstance(exc, RepoUnavailable):
|
||||||
|
raise
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
except RepoUnavailable:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# 把查询结果行收成 CartRow。
|
||||||
|
def _row_from_cursor(cur, rec) -> CartRow:
|
||||||
|
cols = [item[0].lower() for item in cur.description]
|
||||||
|
return _row_from_dict(dict(zip(cols, rec)))
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,20 @@
|
||||||
"""再搜条形码,保留原始商品字段给加车用。"""
|
# 再搜条形码,保留原始商品字段给加车用。
|
||||||
|
#
|
||||||
|
# 导入路径由 selection_path 放进 rpa_selection,这里不再改 sys.path。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
import selection_path # noqa: F401 先把 rpa_selection 放进导入路径
|
||||||
from cart_match import CartOffer
|
from cart_match import CartOffer
|
||||||
from yaoex_login import SELECTION_ROOT
|
from search_yaoex import map_yaoex_products, query_yaoex_products
|
||||||
|
from search_ybm import map_ybm_products, query_ybm_products
|
||||||
if str(SELECTION_ROOT) not in sys.path:
|
|
||||||
sys.path.append(str(SELECTION_ROOT))
|
|
||||||
|
|
||||||
from search_yaoex import map_yaoex_products, query_yaoex_products # noqa: E402
|
|
||||||
from search_ybm import map_ybm_products, query_ybm_products # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
|
# 按条形码搜 1 号药城,结果转成 CartOffer 并带上原始商品字段。
|
||||||
def search_yaoex_offers(
|
def search_yaoex_offers(
|
||||||
auth,
|
auth,
|
||||||
query_key: str,
|
query_key: str,
|
||||||
|
|
@ -42,6 +40,7 @@ def search_yaoex_offers(
|
||||||
return offers
|
return offers
|
||||||
|
|
||||||
|
|
||||||
|
# 按条形码搜药帮忙,结果转成 CartOffer 并带上原始商品字段。
|
||||||
def search_ybm_offers(
|
def search_ybm_offers(
|
||||||
cookies: dict[str, str],
|
cookies: dict[str, str],
|
||||||
query_key: str,
|
query_key: str,
|
||||||
|
|
@ -70,6 +69,7 @@ def search_ybm_offers(
|
||||||
return offers
|
return offers
|
||||||
|
|
||||||
|
|
||||||
|
# 从药帮忙搜索行抽出商品原始字段,补上 skuId。
|
||||||
def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]:
|
def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
product = row.get("productInfo")
|
product = row.get("productInfo")
|
||||||
raw = dict(product) if isinstance(product, dict) else dict(row)
|
raw = dict(product) if isinstance(product, dict) else dict(row)
|
||||||
|
|
@ -78,6 +78,7 @@ def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
# 把选品候选加上原始字段收成加购用的 CartOffer。
|
||||||
def _from_candidate(candidate, raw: dict[str, Any]) -> CartOffer:
|
def _from_candidate(candidate, raw: dict[str, Any]) -> CartOffer:
|
||||||
extra = dict(candidate.extra)
|
extra = dict(candidate.extra)
|
||||||
extra["raw"] = raw
|
extra["raw"] = raw
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""1 号药城加入购物车。"""
|
# 1 号药城加入购物车。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
@ -16,6 +17,7 @@ GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
|
||||||
YAOEX_ADD_URL = f"{GATEWAY}/small/shopCart/addShopCart"
|
YAOEX_ADD_URL = f"{GATEWAY}/small/shopCart/addShopCart"
|
||||||
|
|
||||||
|
|
||||||
|
# 按再搜到的 sku 把数量加入 1 号药城购物车。没有 sku 当系统失败,不改业务原因。
|
||||||
def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Session | None = None) -> AddOutcome:
|
def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Session | None = None) -> AddOutcome:
|
||||||
ids = _ids(offer)
|
ids = _ids(offer)
|
||||||
if not ids["skuId"] and not ids["spuCode"]:
|
if not ids["skuId"] and not ids["spuCode"]:
|
||||||
|
|
@ -48,6 +50,7 @@ def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Sessio
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 从报价里取出加车要的 sku、spu 和商家编码。
|
||||||
def _ids(offer: CartOffer) -> dict[str, str]:
|
def _ids(offer: CartOffer) -> dict[str, str]:
|
||||||
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
|
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
|
|
@ -58,6 +61,7 @@ def _ids(offer: CartOffer) -> dict[str, str]:
|
||||||
return {"skuId": sku, "spuCode": spu, "sellerCode": seller}
|
return {"skuId": sku, "spuCode": spu, "sellerCode": seller}
|
||||||
|
|
||||||
|
|
||||||
|
# 拼 1 号药城表单:带上登录 token,再叠上加车字段。
|
||||||
def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
|
def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
|
||||||
payload = {
|
payload = {
|
||||||
"traderName": "yaoex_pc",
|
"traderName": "yaoex_pc",
|
||||||
|
|
@ -75,6 +79,7 @@ def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
# 1 号药城加车请求头。来源页是购物车。
|
||||||
def _headers() -> dict[str, str]:
|
def _headers() -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
|
@ -87,6 +92,7 @@ def _headers() -> dict[str, str]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
|
||||||
def _outcome_from_json(payload: object) -> AddOutcome:
|
def _outcome_from_json(payload: object) -> AddOutcome:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
|
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""药帮忙加入购物车。"""
|
# 药帮忙加入购物车。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -12,6 +13,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product"
|
YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product"
|
||||||
|
|
||||||
|
|
||||||
|
# 按再搜到的 sku 把数量加入药帮忙购物车。没有 sku 当系统失败。
|
||||||
def add_ybm_cart(
|
def add_ybm_cart(
|
||||||
cookies: dict[str, str],
|
cookies: dict[str, str],
|
||||||
offer: CartOffer,
|
offer: CartOffer,
|
||||||
|
|
@ -44,6 +46,7 @@ def add_ybm_cart(
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 从报价里取药帮忙加车用的 skuId。
|
||||||
def _sku_id(offer: CartOffer) -> str:
|
def _sku_id(offer: CartOffer) -> str:
|
||||||
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
|
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
|
|
@ -51,6 +54,7 @@ def _sku_id(offer: CartOffer) -> str:
|
||||||
return str(raw.get("skuId") or offer.sku_id or "")
|
return str(raw.get("skuId") or offer.sku_id or "")
|
||||||
|
|
||||||
|
|
||||||
|
# 药帮忙加车请求头。
|
||||||
def _headers() -> dict[str, str]:
|
def _headers() -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"Accept": "application/json, text/plain, */*",
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
|
@ -65,6 +69,7 @@ def _headers() -> dict[str, str]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
|
||||||
def _outcome_from_json(payload: object) -> AddOutcome:
|
def _outcome_from_json(payload: object) -> AddOutcome:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
|
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""Oracle 11.2 thick 连接。环境变量与 rpa_oms / rpa_selection 相同。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
CREATE_USER_NAME = "rpa-cart"
|
|
||||||
|
|
||||||
_client_ready = False
|
|
||||||
|
|
||||||
|
|
||||||
class RepoUnavailable(Exception):
|
|
||||||
def __init__(self, message: str = "数据库不可用"):
|
|
||||||
self.message = message
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
def oracle_dsn() -> tuple[str, str, str]:
|
|
||||||
user = os.getenv("RPA_ORACLE_USER", "")
|
|
||||||
password = os.getenv("RPA_ORACLE_PASSWORD", "")
|
|
||||||
host = os.getenv("RPA_ORACLE_HOST", "")
|
|
||||||
if not user or not password or not host:
|
|
||||||
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
|
|
||||||
port = os.getenv("RPA_ORACLE_PORT", "1521")
|
|
||||||
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
|
|
||||||
return user, password, f"{host}:{port}/{service}"
|
|
||||||
|
|
||||||
|
|
||||||
def connect_from_env():
|
|
||||||
user, password, dsn = oracle_dsn()
|
|
||||||
_ensure_client()
|
|
||||||
import oracledb
|
|
||||||
|
|
||||||
return oracledb.connect(user=user, password=password, dsn=dsn)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_client() -> None:
|
|
||||||
global _client_ready
|
|
||||||
if _client_ready:
|
|
||||||
return
|
|
||||||
import oracledb
|
|
||||||
|
|
||||||
if os.getenv("RPA_ORACLE_THICK", "1") == "1":
|
|
||||||
lib_dir = os.getenv("RPA_ORACLE_CLIENT_LIB", "")
|
|
||||||
kwargs = {"lib_dir": lib_dir} if lib_dir else {}
|
|
||||||
oracledb.init_oracle_client(**kwargs)
|
|
||||||
_client_ready = True
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""1 号药城支付落表班次。"""
|
# 1 号药城支付落表班次。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -6,6 +7,7 @@ from dataclasses import dataclass
|
||||||
from settle import MallLine, match_pay_lines
|
from settle import MallLine, match_pay_lines
|
||||||
|
|
||||||
|
|
||||||
|
# 一次支付落表的计数:写入、状态跳过、订单号重复、对不上。
|
||||||
@dataclass
|
@dataclass
|
||||||
class PaySummary:
|
class PaySummary:
|
||||||
source_lines: int = 0
|
source_lines: int = 0
|
||||||
|
|
@ -15,11 +17,13 @@ class PaySummary:
|
||||||
unmatched: int = 0
|
unmatched: int = 0
|
||||||
unmatched_reasons: dict[str, int] | None = None
|
unmatched_reasons: dict[str, int] | None = None
|
||||||
|
|
||||||
|
# 未对上的原因计数缺省为空字典,避免调用方再判 None。
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.unmatched_reasons is None:
|
if self.unmatched_reasons is None:
|
||||||
self.unmatched_reasons = {}
|
self.unmatched_reasons = {}
|
||||||
|
|
||||||
|
|
||||||
|
# 把 1 号药城已支付行写入表 2 的支付行。不对加购行。dry_run 只统计不落库。
|
||||||
def run_yaoex_pay(
|
def run_yaoex_pay(
|
||||||
repo,
|
repo,
|
||||||
lines: list[MallLine],
|
lines: list[MallLine],
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
"""加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。"""
|
# 加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import tempfile
|
from datetime import date, datetime
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from db import RepoUnavailable
|
from rpa_oracle import RepoUnavailable
|
||||||
from job import run_yaoex_pay
|
from job import run_yaoex_pay
|
||||||
from parse_xls import parse_order_xls
|
from parse_xls import parse_order_xls
|
||||||
from repo import EmptyPayRepo, OraclePayRepo
|
from repo import OraclePayRepo
|
||||||
from yaoex_client import day_range, export_orders
|
from yaoex_client import day_range, export_orders
|
||||||
from yaoex_login import (
|
from yaoex_login import (
|
||||||
BrowserRuntime,
|
BrowserRuntime,
|
||||||
|
|
@ -21,10 +21,33 @@ from yaoex_login import (
|
||||||
)
|
)
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
|
EXPORT_DIR = PROJECT_ROOT / "exports" / "yaoex"
|
||||||
YAOEX_PLATFORM_TYPE = 66
|
YAOEX_PLATFORM_TYPE = 66
|
||||||
YBM_PLATFORM_TYPE = 48
|
YBM_PLATFORM_TYPE = 48
|
||||||
|
DEFAULT_CART_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
# 导出文件按订单日放进固定目录。同一秒再导一次加序号,不盖掉上一份。
|
||||||
|
def pay_export_path(
|
||||||
|
begin: str,
|
||||||
|
end: str,
|
||||||
|
exported_at: datetime,
|
||||||
|
root: Path = EXPORT_DIR,
|
||||||
|
) -> Path:
|
||||||
|
start_day = begin[:10]
|
||||||
|
end_day = end[:10]
|
||||||
|
folder_name = start_day if start_day == end_day else f"{start_day}_{end_day}"
|
||||||
|
directory = root / folder_name
|
||||||
|
stamp = exported_at.strftime("%H%M%S")
|
||||||
|
path = directory / f"{stamp}.xls"
|
||||||
|
suffix = 2
|
||||||
|
while path.exists():
|
||||||
|
path = directory / f"{stamp}_{suffix}.xls"
|
||||||
|
suffix += 1
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# 区分支付落表和加购。没写子命令时走支付。
|
||||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
argv = list(argv) if argv is not None else None
|
argv = list(argv) if argv is not None else None
|
||||||
if argv is None:
|
if argv is None:
|
||||||
|
|
@ -43,26 +66,24 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
# 支付落表没有额外参数:登录、导出当天、写表 2。
|
||||||
def _parse_pay_args(argv: list[str]) -> argparse.Namespace:
|
def _parse_pay_args(argv: list[str]) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="1 号药城支付落表")
|
parser = argparse.ArgumentParser(description="1 号药城支付落表:登录、导出当天、解析、写库")
|
||||||
parser.add_argument("--xls", type=Path, default=None, help="已导出的订单明细 xls,跳过商城导出")
|
|
||||||
parser.add_argument(
|
|
||||||
"--days",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
help="导出日期跨度,0=仅当天。与 --xls 互斥时仍用于无 xls 的导出",
|
|
||||||
)
|
|
||||||
parser.add_argument("--day", type=str, default=None, help="结束日期 YYYY-MM-DD,默认今天")
|
|
||||||
parser.add_argument("--dry-run", action="store_true", help="解析但不写库")
|
|
||||||
parser.add_argument("--fresh-login", action="store_true", help="忽略登录缓存")
|
|
||||||
parser.add_argument("--secrets", type=Path, default=None)
|
|
||||||
parser.add_argument("--yaoex-auth", type=Path, default=None)
|
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def _parse_cart_args(argv: list[str]) -> argparse.Namespace:
|
def _parse_cart_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
"""解析加购参数:领取条数、平台、是否忽略登录缓存。
|
||||||
|
|
||||||
|
不传 --limit 时用 20,不再把空值当成全部领走。
|
||||||
|
"""
|
||||||
parser = argparse.ArgumentParser(description="加购班:领表 2 选品成功行,按仓加进购物车")
|
parser = argparse.ArgumentParser(description="加购班:领表 2 选品成功行,按仓加进购物车")
|
||||||
parser.add_argument("--limit", type=int, default=None, help="本班最多领取 N 行")
|
parser.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_CART_LIMIT,
|
||||||
|
help="本班最多领取 N 行,默认 20",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--platform",
|
"--platform",
|
||||||
choices=["all", "yaoex", "ybm"],
|
choices=["all", "yaoex", "ybm"],
|
||||||
|
|
@ -70,32 +91,25 @@ def _parse_cart_args(argv: list[str]) -> argparse.Namespace:
|
||||||
help="all=两平台都跑",
|
help="all=两平台都跑",
|
||||||
)
|
)
|
||||||
parser.add_argument("--fresh-login", action="store_true", help="忽略登录缓存")
|
parser.add_argument("--fresh-login", action="store_true", help="忽略登录缓存")
|
||||||
parser.add_argument("--secrets", type=Path, default=None)
|
|
||||||
parser.add_argument("--yaoex-auth", type=Path, default=None)
|
parser.add_argument("--yaoex-auth", type=Path, default=None)
|
||||||
parser.add_argument("--ybm-cookies", type=Path, default=None)
|
parser.add_argument("--ybm-cookies", type=Path, default=None)
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
# 入口。cart 子命令加购,否则跑 1 号药城支付落表。
|
||||||
def main(argv: list[str] | None = None, repo=None) -> None:
|
def main(argv: list[str] | None = None, repo=None) -> None:
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
if args.command == "cart":
|
if args.command == "cart":
|
||||||
main_cart(args, repo=repo)
|
main_cart(args, repo=repo)
|
||||||
return
|
return
|
||||||
main_pay(args, repo=repo)
|
main_pay(repo=repo)
|
||||||
|
|
||||||
|
|
||||||
def main_pay(args: argparse.Namespace, repo=None) -> None:
|
# 登录 1 号药城,导出当天订单,已支付行写入表 2。不对加购行。
|
||||||
|
def main_pay(repo=None) -> None:
|
||||||
load_cart_dotenv()
|
load_cart_dotenv()
|
||||||
xls_path = args.xls
|
|
||||||
tmp_xls: Path | None = None
|
|
||||||
|
|
||||||
if xls_path is None:
|
|
||||||
try:
|
try:
|
||||||
auth = resolve_yaoex_auth(
|
auth = resolve_yaoex_auth()
|
||||||
secrets_path=args.secrets,
|
|
||||||
auth_path=args.yaoex_auth,
|
|
||||||
fresh=args.fresh_login,
|
|
||||||
)
|
|
||||||
token = auth.token
|
token = auth.token
|
||||||
print("1号药城认证就绪。")
|
print("1号药城认证就绪。")
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
|
|
@ -103,14 +117,13 @@ def main_pay(args: argparse.Namespace, repo=None) -> None:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"1号药城登录失败:{exc}")
|
print(f"1号药城登录失败:{exc}")
|
||||||
raise SystemExit(2) from exc
|
raise SystemExit(2) from exc
|
||||||
end = date.fromisoformat(args.day) if args.day else date.today()
|
end = date.today()
|
||||||
begin, end_ts = day_range(end, args.days)
|
begin, end_ts = day_range(end, 0)
|
||||||
print(f"导出 {begin} ~ {end_ts}")
|
print(f"导出 {begin} ~ {end_ts}")
|
||||||
content = export_orders(token, begin=begin, end=end_ts)
|
content = export_orders(token, begin=begin, end=end_ts)
|
||||||
tmp_dir = Path(tempfile.mkdtemp(prefix="yaoex_pay_"))
|
xls_path = pay_export_path(begin, end_ts, datetime.now())
|
||||||
tmp_xls = tmp_dir / "orders.xls"
|
xls_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_xls.write_bytes(content)
|
xls_path.write_bytes(content)
|
||||||
xls_path = tmp_xls
|
|
||||||
print(f"导出文件 {xls_path} ({len(content)} bytes)")
|
print(f"导出文件 {xls_path} ({len(content)} bytes)")
|
||||||
|
|
||||||
lines = parse_order_xls(xls_path)
|
lines = parse_order_xls(xls_path)
|
||||||
|
|
@ -120,13 +133,10 @@ def main_pay(args: argparse.Namespace, repo=None) -> None:
|
||||||
try:
|
try:
|
||||||
repo = OraclePayRepo.from_env()
|
repo = OraclePayRepo.from_env()
|
||||||
except RepoUnavailable as exc:
|
except RepoUnavailable as exc:
|
||||||
if not args.dry_run:
|
|
||||||
print(f"数据库不可用:{exc}")
|
print(f"数据库不可用:{exc}")
|
||||||
raise SystemExit(2) from exc
|
raise SystemExit(2) from exc
|
||||||
print(f"无库,dry-run 只解析:{exc}")
|
|
||||||
repo = EmptyPayRepo()
|
|
||||||
|
|
||||||
summary = run_yaoex_pay(repo, lines, dry_run=args.dry_run)
|
summary = run_yaoex_pay(repo, lines)
|
||||||
print(
|
print(
|
||||||
"落表 "
|
"落表 "
|
||||||
f"inserted={summary.inserted} "
|
f"inserted={summary.inserted} "
|
||||||
|
|
@ -137,6 +147,7 @@ def main_pay(args: argparse.Namespace, repo=None) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 领表 2 选品成功行,按条形码再搜并对上商家后加车。一行失败只改那一行。
|
||||||
def main_cart(args: argparse.Namespace, repo=None) -> None:
|
def main_cart(args: argparse.Namespace, repo=None) -> None:
|
||||||
from cart_job import run_cart_batch
|
from cart_job import run_cart_batch
|
||||||
from cart_repo import OracleCartRepo
|
from cart_repo import OracleCartRepo
|
||||||
|
|
@ -168,7 +179,6 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
|
||||||
if want_yaoex:
|
if want_yaoex:
|
||||||
try:
|
try:
|
||||||
yaoex_auth = resolve_yaoex_auth(
|
yaoex_auth = resolve_yaoex_auth(
|
||||||
secrets_path=args.secrets,
|
|
||||||
auth_path=args.yaoex_auth,
|
auth_path=args.yaoex_auth,
|
||||||
fresh=args.fresh_login,
|
fresh=args.fresh_login,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
|
|
@ -182,7 +192,6 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
|
||||||
if want_ybm:
|
if want_ybm:
|
||||||
try:
|
try:
|
||||||
ybm_cookies = resolve_ybm_cookies(
|
ybm_cookies = resolve_ybm_cookies(
|
||||||
secrets_path=args.secrets,
|
|
||||||
cookie_path=args.ybm_cookies,
|
cookie_path=args.ybm_cookies,
|
||||||
fresh=args.fresh_login,
|
fresh=args.fresh_login,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
|
|
@ -200,11 +209,13 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
|
||||||
|
|
||||||
if yaoex_auth is not None:
|
if yaoex_auth is not None:
|
||||||
|
|
||||||
|
# 用本班已登录的 1 号药城会话按条形码再搜。
|
||||||
def search_yaoex(query_key: str):
|
def search_yaoex(query_key: str):
|
||||||
return search_yaoex_offers(
|
return search_yaoex_offers(
|
||||||
yaoex_auth, query_key, session=yaoex_session, cache=yaoex_cache
|
yaoex_auth, query_key, session=yaoex_session, cache=yaoex_cache
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 把对上的仓按数量加入 1 号药城购物车。
|
||||||
def add_yaoex(offer, qty: int):
|
def add_yaoex(offer, qty: int):
|
||||||
return add_yaoex_cart(yaoex_auth, offer, qty, session=yaoex_session)
|
return add_yaoex_cart(yaoex_auth, offer, qty, session=yaoex_session)
|
||||||
|
|
||||||
|
|
@ -213,11 +224,13 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
|
||||||
|
|
||||||
if ybm_cookies is not None:
|
if ybm_cookies is not None:
|
||||||
|
|
||||||
|
# 用本班已登录的药帮忙会话按条形码再搜。
|
||||||
def search_ybm(query_key: str):
|
def search_ybm(query_key: str):
|
||||||
return search_ybm_offers(
|
return search_ybm_offers(
|
||||||
ybm_cookies, query_key, session=ybm_session, cache=ybm_cache
|
ybm_cookies, query_key, session=ybm_session, cache=ybm_cache
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 把对上的仓按数量加入药帮忙购物车。
|
||||||
def add_ybm(offer, qty: int):
|
def add_ybm(offer, qty: int):
|
||||||
return add_ybm_cart(ybm_cookies, offer, qty, session=ybm_session)
|
return add_ybm_cart(ybm_cookies, offer, qty, session=ybm_session)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""解析 1 号药城「订单明细」xls。"""
|
# 解析 1 号药城「订单明细」xls。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
@ -24,6 +25,7 @@ _REQUIRED = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 读 1 号药城订单明细表。缺列就失败。订单号为空的行丢掉。
|
||||||
def parse_order_xls(path: str | Path) -> list[MallLine]:
|
def parse_order_xls(path: str | Path) -> list[MallLine]:
|
||||||
book = xlrd.open_workbook(str(path))
|
book = xlrd.open_workbook(str(path))
|
||||||
sheet = book.sheet_by_index(0)
|
sheet = book.sheet_by_index(0)
|
||||||
|
|
@ -57,6 +59,7 @@ def parse_order_xls(path: str | Path) -> list[MallLine]:
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
# 单元格收成字符串。整数浮点去掉小数点,避免订单号变成 123.0。
|
||||||
def _text(value: object) -> str:
|
def _text(value: object) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -65,6 +68,7 @@ def _text(value: object) -> str:
|
||||||
return str(value).strip()
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# 条码只留第一段。逗号后的附加码丢掉,末尾 .0 去掉。
|
||||||
def _barcode(value: object) -> str:
|
def _barcode(value: object) -> str:
|
||||||
text = _text(value)
|
text = _text(value)
|
||||||
if "," in text:
|
if "," in text:
|
||||||
|
|
@ -75,6 +79,7 @@ def _barcode(value: object) -> str:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# 单元格收成金额。空值当 0。
|
||||||
def _decimal(value: object) -> Decimal:
|
def _decimal(value: object) -> Decimal:
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return Decimal("0")
|
return Decimal("0")
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ dev = [
|
||||||
package = false
|
package = false
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = [".", "..", "../rpa_selection"]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-q"
|
addopts = "-q"
|
||||||
|
|
||||||
|
|
|
||||||
116
rpa_cart/repo.py
116
rpa_cart/repo.py
|
|
@ -1,10 +1,14 @@
|
||||||
"""表 2:写支付行。"""
|
# 表 2:写支付行。
|
||||||
|
#
|
||||||
|
# 连接从 rpa_oracle 池取。库故障对外「数据库不可用」,不含 ORA 原文。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from db import CREATE_USER_NAME, RepoUnavailable, connect_from_env, oracle_dsn
|
from rpa_oracle import RepoUnavailable, job_pool
|
||||||
|
|
||||||
|
CREATE_USER_NAME = "rpa-cart"
|
||||||
from settle import YAOEX_PLATFORM_TYPE
|
from settle import YAOEX_PLATFORM_TYPE
|
||||||
|
|
||||||
_SELECT_PAY_ORDER_NOS = """
|
_SELECT_PAY_ORDER_NOS = """
|
||||||
|
|
@ -48,50 +52,70 @@ _PAY_BIND_KEYS = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EmptyPayRepo:
|
# 回滚当前事务。回滚本身失败则忽略。
|
||||||
def load_existing_pay_order_nos(self) -> set[str]:
|
def _rollback(conn) -> None:
|
||||||
return set()
|
|
||||||
|
|
||||||
def save_pay_rows(self, rows: list[dict[str, Any]]) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class OraclePayRepo:
|
|
||||||
def __init__(self, connect: Callable):
|
|
||||||
self._connect = connect
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_env(cls) -> "OraclePayRepo":
|
|
||||||
oracle_dsn()
|
|
||||||
return cls(connect=connect_from_env)
|
|
||||||
|
|
||||||
def load_existing_pay_order_nos(self) -> set[str]:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(_SELECT_PAY_ORDER_NOS, {"platform_type": YAOEX_PLATFORM_TYPE})
|
|
||||||
return {str(order_no) for (order_no,) in cur.fetchall() if order_no}
|
|
||||||
except Exception as exc:
|
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def save_pay_rows(self, rows: list[dict[str, Any]]) -> None:
|
|
||||||
if not rows:
|
|
||||||
return
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
for row in rows:
|
|
||||||
payload = {key: row.get(key) for key in _PAY_BIND_KEYS}
|
|
||||||
payload["create_user_name"] = CREATE_USER_NAME
|
|
||||||
cur.execute(_INSERT_PAY, payload)
|
|
||||||
conn.commit()
|
|
||||||
except Exception as exc:
|
|
||||||
try:
|
try:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
conn.close()
|
# 支付班次连 Oracle 的仓库,管表 2 operate_type=2 的读写。
|
||||||
|
class OraclePayRepo:
|
||||||
|
# 挂上连接池。方法里按次 acquire。
|
||||||
|
def __init__(self, pool):
|
||||||
|
self._pool = pool
|
||||||
|
|
||||||
|
# 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "OraclePayRepo":
|
||||||
|
return cls(pool=job_pool())
|
||||||
|
|
||||||
|
# 读表 2 已有的 1 号药城支付单号,空值丢掉。
|
||||||
|
def load_existing_pay_order_nos(self) -> set[str]:
|
||||||
|
try:
|
||||||
|
with self._pool.acquire() as conn:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
_SELECT_PAY_ORDER_NOS,
|
||||||
|
{"platform_type": YAOEX_PLATFORM_TYPE},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
str(order_no)
|
||||||
|
for (order_no,) in cur.fetchall()
|
||||||
|
if order_no
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
if isinstance(exc, RepoUnavailable):
|
||||||
|
raise
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
except RepoUnavailable:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
# 把支付行一次 executemany 插入表 2。空列表直接返回。同一连接同一事务。
|
||||||
|
def save_pay_rows(self, rows: list[dict[str, Any]]) -> None:
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
binds = []
|
||||||
|
for row in rows:
|
||||||
|
payload = {key: row.get(key) for key in _PAY_BIND_KEYS}
|
||||||
|
payload["create_user_name"] = CREATE_USER_NAME
|
||||||
|
binds.append(payload)
|
||||||
|
try:
|
||||||
|
with self._pool.acquire() as conn:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany(_INSERT_PAY, binds)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
_rollback(conn)
|
||||||
|
if isinstance(exc, RepoUnavailable):
|
||||||
|
raise
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
except RepoUnavailable:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
# 把 rpa_selection 放进导入路径。登录和搜索模块自己不改 sys.path。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SELECTION_ROOT = Path(__file__).resolve().parent.parent / "rpa_selection"
|
||||||
|
if str(SELECTION_ROOT) not in sys.path:
|
||||||
|
sys.path.append(str(SELECTION_ROOT))
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""1 号药城导出行 → 表 2 operate_type=2。不对加购行。"""
|
# 1 号药城导出行 → 表 2 operate_type=2。不对加购行。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -13,14 +14,17 @@ UNASSIGNED_OMS_ID = 0
|
||||||
PAID_STATUSES = {"待发货", "待收货", "已完成"}
|
PAID_STATUSES = {"待发货", "待收货", "已完成"}
|
||||||
|
|
||||||
|
|
||||||
|
# 金额收到四位小数,四舍五入。接口 2 组包也按这个精度。
|
||||||
def money4(value: object) -> Decimal:
|
def money4(value: object) -> Decimal:
|
||||||
return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
# 生成支付明细号。表 2 用它区分同一订单下的不同行。
|
||||||
def new_pay_dtl_id() -> str:
|
def new_pay_dtl_id() -> str:
|
||||||
return uuid.uuid4().hex
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
# 1 号药城导出的一行订单明细。只取已支付状态落表。
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MallLine:
|
class MallLine:
|
||||||
order_no: str
|
order_no: str
|
||||||
|
|
@ -37,6 +41,7 @@ class MallLine:
|
||||||
order_paid: Decimal
|
order_paid: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
# 支付匹配结果:可落库的行、对不上的行、重复订单、状态未付。
|
||||||
@dataclass
|
@dataclass
|
||||||
class MatchResult:
|
class MatchResult:
|
||||||
rows: list[dict[str, Any]]
|
rows: list[dict[str, Any]]
|
||||||
|
|
@ -45,6 +50,7 @@ class MatchResult:
|
||||||
skipped_status: list[MallLine]
|
skipped_status: list[MallLine]
|
||||||
|
|
||||||
|
|
||||||
|
# 筛出待发货、待收货、已完成的行。同一支付订单号已落过的跳过。条码超长不对上。
|
||||||
def match_pay_lines(
|
def match_pay_lines(
|
||||||
lines: list[MallLine],
|
lines: list[MallLine],
|
||||||
existing_order_nos: set[str],
|
existing_order_nos: set[str],
|
||||||
|
|
@ -64,6 +70,7 @@ def match_pay_lines(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# 把一条已支付明细收成表 2 支付行。operate_type=2,oms_id 先填 0,不改加购行。
|
||||||
def build_pay_row(line: MallLine) -> dict[str, Any]:
|
def build_pay_row(line: MallLine) -> dict[str, Any]:
|
||||||
price = money4(line.pay_price)
|
price = money4(line.pay_price)
|
||||||
qty = int(line.qty)
|
qty = int(line.qty)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
# 加购班次:拆仓加车不插行、系统故障短码。
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from cart_job import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, AddOutcome, run_cart_batch
|
from cart_job import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, AddOutcome, run_cart_batch
|
||||||
from cart_match import CartOffer
|
from cart_match import CartOffer
|
||||||
from cart_repo import STAMP_DONE, STAMP_SELECTION, MemoryCartRepo
|
from cart_repo import STAMP_CLAIMING, STAMP_DONE, STAMP_SELECTION, MemoryCartRepo
|
||||||
|
|
||||||
|
|
||||||
HANGZHOU = "杭州小药药自营旗舰店"
|
HANGZHOU = "杭州小药药自营旗舰店"
|
||||||
|
|
@ -9,6 +14,7 @@ YBM = 48
|
||||||
YAOEX = 66
|
YAOEX = 66
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条待加购表 2 行。
|
||||||
def _row(**overrides) -> dict:
|
def _row(**overrides) -> dict:
|
||||||
data = {
|
data = {
|
||||||
"fid": 1,
|
"fid": 1,
|
||||||
|
|
@ -27,6 +33,7 @@ def _row(**overrides) -> dict:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条能对上仓的加购候选。
|
||||||
def _offer(vendor: str, **overrides) -> CartOffer:
|
def _offer(vendor: str, **overrides) -> CartOffer:
|
||||||
data = dict(
|
data = dict(
|
||||||
sku_id="S-" + vendor[:2],
|
sku_id="S-" + vendor[:2],
|
||||||
|
|
@ -39,6 +46,7 @@ def _offer(vendor: str, **overrides) -> CartOffer:
|
||||||
return CartOffer(**data)
|
return CartOffer(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# 杭州 3 + 广东 7 两行都加车,表 2 仍两行。
|
||||||
def test_job_adds_split_rows_without_insert():
|
def test_job_adds_split_rows_without_insert():
|
||||||
repo = MemoryCartRepo(
|
repo = MemoryCartRepo(
|
||||||
[
|
[
|
||||||
|
|
@ -48,18 +56,22 @@ def test_job_adds_split_rows_without_insert():
|
||||||
)
|
)
|
||||||
added: list[tuple[int, str, int]] = []
|
added: list[tuple[int, str, int]] = []
|
||||||
|
|
||||||
|
# 杭州仓有货。
|
||||||
def search_ybm(bar_code: str):
|
def search_ybm(bar_code: str):
|
||||||
assert bar_code == "06901234567890"
|
assert bar_code == "06901234567890"
|
||||||
return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)]
|
return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)]
|
||||||
|
|
||||||
|
# 广东仓有货。
|
||||||
def search_yaoex(bar_code: str):
|
def search_yaoex(bar_code: str):
|
||||||
assert bar_code == "06901234567890"
|
assert bar_code == "06901234567890"
|
||||||
return [_offer(GUANGDONG, sku_id="GD", stock=20.0)]
|
return [_offer(GUANGDONG, sku_id="GD", stock=20.0)]
|
||||||
|
|
||||||
|
# 记下药帮忙加车。
|
||||||
def add_ybm(offer: CartOffer, qty: int):
|
def add_ybm(offer: CartOffer, qty: int):
|
||||||
added.append((YBM, offer.sku_id, qty))
|
added.append((YBM, offer.sku_id, qty))
|
||||||
return AddOutcome(kind=KIND_OK)
|
return AddOutcome(kind=KIND_OK)
|
||||||
|
|
||||||
|
# 记下 1 号药城加车。
|
||||||
def add_yaoex(offer: CartOffer, qty: int):
|
def add_yaoex(offer: CartOffer, qty: int):
|
||||||
added.append((YAOEX, offer.sku_id, qty))
|
added.append((YAOEX, offer.sku_id, qty))
|
||||||
return AddOutcome(kind=KIND_OK)
|
return AddOutcome(kind=KIND_OK)
|
||||||
|
|
@ -82,9 +94,45 @@ def test_job_adds_split_rows_without_insert():
|
||||||
assert repo.rows[1]["push_status"] == 0
|
assert repo.rows[1]["push_status"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# 同一 omsId 一仓成功一仓失败:失败行原因带未加齐,成功行不回退。
|
||||||
|
def test_job_flags_incomplete_oms_when_one_row_fails():
|
||||||
|
repo = MemoryCartRepo(
|
||||||
|
[
|
||||||
|
_row(fid=1, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3),
|
||||||
|
_row(fid=2, platform_type=YAOEX, supplier_name=GUANGDONG, purchase_qty=7),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 杭州仓有货。
|
||||||
|
def search_ybm(bar_code: str):
|
||||||
|
return [_offer(HANGZHOU, stock=5.0)]
|
||||||
|
|
||||||
|
# 广东仓库存不够。
|
||||||
|
def search_yaoex(bar_code: str):
|
||||||
|
return [_offer(GUANGDONG, stock=1.0)]
|
||||||
|
|
||||||
|
summary = run_cart_batch(
|
||||||
|
repo,
|
||||||
|
searchers={YBM: search_ybm, YAOEX: search_yaoex},
|
||||||
|
adders={
|
||||||
|
YBM: lambda offer, qty: AddOutcome(kind=KIND_OK),
|
||||||
|
YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK),
|
||||||
|
},
|
||||||
|
login_errors={},
|
||||||
|
)
|
||||||
|
assert summary.added == 1
|
||||||
|
assert summary.failed == 1
|
||||||
|
assert repo.rows[1]["status"] == 1
|
||||||
|
assert repo.rows[1]["fail_reason"] is None
|
||||||
|
assert repo.rows[2]["status"] == 2
|
||||||
|
assert repo.rows[2]["fail_reason"] == "未加齐:库存不足"
|
||||||
|
|
||||||
|
|
||||||
|
# 库存不够时该行 status=2,原因是库存不足。
|
||||||
def test_job_marks_out_of_stock():
|
def test_job_marks_out_of_stock():
|
||||||
repo = MemoryCartRepo([_row(purchase_qty=10)])
|
repo = MemoryCartRepo([_row(purchase_qty=10)])
|
||||||
|
|
||||||
|
# 库存只有 4。
|
||||||
def search_yaoex(bar_code: str):
|
def search_yaoex(bar_code: str):
|
||||||
return [_offer(GUANGDONG, stock=4.0)]
|
return [_offer(GUANGDONG, stock=4.0)]
|
||||||
|
|
||||||
|
|
@ -99,9 +147,11 @@ def test_job_marks_out_of_stock():
|
||||||
assert repo.rows[1]["status"] == 2
|
assert repo.rows[1]["status"] == 2
|
||||||
assert repo.rows[1]["fail_reason"] == "库存不足"
|
assert repo.rows[1]["fail_reason"] == "库存不足"
|
||||||
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
||||||
|
assert repo.shift_errors == []
|
||||||
|
|
||||||
|
|
||||||
def test_job_marks_login_fail_as_system_error():
|
def test_job_marks_login_fail_as_system_error():
|
||||||
|
"""登录失败把戳改回系统管理员,status 保持 1,下一班还能领。"""
|
||||||
repo = MemoryCartRepo(
|
repo = MemoryCartRepo(
|
||||||
[
|
[
|
||||||
_row(fid=1, platform_type=YAOEX),
|
_row(fid=1, platform_type=YAOEX),
|
||||||
|
|
@ -110,9 +160,11 @@ def test_job_marks_login_fail_as_system_error():
|
||||||
)
|
)
|
||||||
ybm_added = []
|
ybm_added = []
|
||||||
|
|
||||||
|
# 杭州仓有货。
|
||||||
def search_ybm(bar_code: str):
|
def search_ybm(bar_code: str):
|
||||||
return [_offer(HANGZHOU)]
|
return [_offer(HANGZHOU)]
|
||||||
|
|
||||||
|
# 记下药帮忙加车数量。
|
||||||
def add_ybm(offer: CartOffer, qty: int):
|
def add_ybm(offer: CartOffer, qty: int):
|
||||||
ybm_added.append(qty)
|
ybm_added.append(qty)
|
||||||
return AddOutcome(kind=KIND_OK)
|
return AddOutcome(kind=KIND_OK)
|
||||||
|
|
@ -125,18 +177,28 @@ def test_job_marks_login_fail_as_system_error():
|
||||||
)
|
)
|
||||||
assert summary.added == 1
|
assert summary.added == 1
|
||||||
assert summary.failed == 1
|
assert summary.failed == 1
|
||||||
assert repo.rows[1]["status"] == 2
|
assert repo.rows[1]["status"] == 1
|
||||||
assert repo.rows[1]["fail_reason"].startswith("系统故障:")
|
assert repo.rows[1]["update_user_name"] == STAMP_SELECTION
|
||||||
assert "账号密码错误" in repo.rows[1]["fail_reason"]
|
assert repo.rows[1]["fail_reason"] == "未加齐:系统故障:LOGIN_FAILED"
|
||||||
|
assert "账号密码错误" not in repo.rows[1]["fail_reason"]
|
||||||
|
assert len(repo.shift_errors) == 1
|
||||||
|
assert repo.shift_errors[0].shift_name == "加购"
|
||||||
|
assert repo.shift_errors[0].biz_key == "1"
|
||||||
|
assert repo.shift_errors[0].error_code == "LOGIN_FAILED"
|
||||||
|
assert repo.shift_errors[0].error_text == "账号密码错误"
|
||||||
assert repo.rows[2]["status"] == 1
|
assert repo.rows[2]["status"] == 1
|
||||||
assert repo.rows[2]["update_user_name"] == STAMP_DONE
|
assert repo.rows[2]["update_user_name"] == STAMP_DONE
|
||||||
assert ybm_added == [3]
|
assert ybm_added == [3]
|
||||||
|
again = repo.claim_pending()
|
||||||
|
assert [row.fid for row in again] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# 已盖加购班的行本班不领。
|
||||||
def test_job_skips_already_stamped_rows():
|
def test_job_skips_already_stamped_rows():
|
||||||
repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)])
|
repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)])
|
||||||
called = []
|
called = []
|
||||||
|
|
||||||
|
# 不应被调用。
|
||||||
def search_yaoex(bar_code: str):
|
def search_yaoex(bar_code: str):
|
||||||
called.append(bar_code)
|
called.append(bar_code)
|
||||||
return [_offer(GUANGDONG)]
|
return [_offer(GUANGDONG)]
|
||||||
|
|
@ -152,9 +214,11 @@ def test_job_skips_already_stamped_rows():
|
||||||
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
||||||
|
|
||||||
|
|
||||||
|
# 对不上 supplier_name 时写未找到对应仓。
|
||||||
def test_job_marks_missing_warehouse():
|
def test_job_marks_missing_warehouse():
|
||||||
repo = MemoryCartRepo([_row()])
|
repo = MemoryCartRepo([_row()])
|
||||||
|
|
||||||
|
# 搜到的是杭州仓,对不上广东。
|
||||||
def search_yaoex(bar_code: str):
|
def search_yaoex(bar_code: str):
|
||||||
return [_offer(HANGZHOU)]
|
return [_offer(HANGZHOU)]
|
||||||
|
|
||||||
|
|
@ -169,29 +233,15 @@ def test_job_marks_missing_warehouse():
|
||||||
assert repo.rows[1]["fail_reason"] == "未找到对应仓"
|
assert repo.rows[1]["fail_reason"] == "未找到对应仓"
|
||||||
|
|
||||||
|
|
||||||
def test_job_marks_pack_reject():
|
# 平台返回业务失败时原样写下原因。
|
||||||
repo = MemoryCartRepo([_row(purchase_qty=7)])
|
|
||||||
|
|
||||||
def search_yaoex(bar_code: str):
|
|
||||||
return [_offer(GUANGDONG, extra={"raw": {"middlePackNum": 10}})]
|
|
||||||
|
|
||||||
summary = run_cart_batch(
|
|
||||||
repo,
|
|
||||||
searchers={YAOEX: search_yaoex},
|
|
||||||
adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)},
|
|
||||||
login_errors={},
|
|
||||||
)
|
|
||||||
assert summary.failed == 1
|
|
||||||
assert repo.rows[1]["status"] == 2
|
|
||||||
assert repo.rows[1]["fail_reason"].startswith("中包装加不进")
|
|
||||||
|
|
||||||
|
|
||||||
def test_job_records_mall_add_failure():
|
def test_job_records_mall_add_failure():
|
||||||
repo = MemoryCartRepo([_row()])
|
repo = MemoryCartRepo([_row()])
|
||||||
|
|
||||||
|
# 广东仓有货。
|
||||||
def search_yaoex(bar_code: str):
|
def search_yaoex(bar_code: str):
|
||||||
return [_offer(GUANGDONG)]
|
return [_offer(GUANGDONG)]
|
||||||
|
|
||||||
|
# 平台返回中包装加不进。
|
||||||
def add_yaoex(offer: CartOffer, qty: int):
|
def add_yaoex(offer: CartOffer, qty: int):
|
||||||
return AddOutcome(kind=KIND_BUSINESS, reason="中包装加不进:数量须为10的倍数")
|
return AddOutcome(kind=KIND_BUSINESS, reason="中包装加不进:数量须为10的倍数")
|
||||||
|
|
||||||
|
|
@ -205,6 +255,7 @@ def test_job_records_mall_add_failure():
|
||||||
assert repo.rows[1]["fail_reason"] == "中包装加不进:数量须为10的倍数"
|
assert repo.rows[1]["fail_reason"] == "中包装加不进:数量须为10的倍数"
|
||||||
|
|
||||||
|
|
||||||
|
# 只跑一个平台时另一平台行仍停在系统管理员。
|
||||||
def test_job_platform_filter_leaves_other_mall_pending():
|
def test_job_platform_filter_leaves_other_mall_pending():
|
||||||
repo = MemoryCartRepo(
|
repo = MemoryCartRepo(
|
||||||
[
|
[
|
||||||
|
|
@ -213,6 +264,7 @@ def test_job_platform_filter_leaves_other_mall_pending():
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 广东仓有货。
|
||||||
def search_yaoex(bar_code: str):
|
def search_yaoex(bar_code: str):
|
||||||
return [_offer(GUANGDONG)]
|
return [_offer(GUANGDONG)]
|
||||||
|
|
||||||
|
|
@ -231,10 +283,12 @@ def test_job_platform_filter_leaves_other_mall_pending():
|
||||||
|
|
||||||
|
|
||||||
def test_job_search_error_is_system_fail():
|
def test_job_search_error_is_system_fail():
|
||||||
|
"""查询失败原因列只写 QUERY_FAILED,戳改回系统管理员,status 保持 1。"""
|
||||||
repo = MemoryCartRepo([_row()])
|
repo = MemoryCartRepo([_row()])
|
||||||
|
|
||||||
|
# 抛带原文的查询错。
|
||||||
def search_yaoex(bar_code: str):
|
def search_yaoex(bar_code: str):
|
||||||
raise RuntimeError("网关 502")
|
raise RuntimeError("网关 502 secret-traceback")
|
||||||
|
|
||||||
summary = run_cart_batch(
|
summary = run_cart_batch(
|
||||||
repo,
|
repo,
|
||||||
|
|
@ -243,5 +297,155 @@ def test_job_search_error_is_system_fail():
|
||||||
login_errors={},
|
login_errors={},
|
||||||
)
|
)
|
||||||
assert summary.failed == 1
|
assert summary.failed == 1
|
||||||
assert repo.rows[1]["fail_reason"].startswith("系统故障:")
|
reason = repo.rows[1]["fail_reason"]
|
||||||
assert "网关 502" in repo.rows[1]["fail_reason"]
|
assert reason == "系统故障:QUERY_FAILED"
|
||||||
|
assert repo.rows[1]["status"] == 1
|
||||||
|
assert repo.rows[1]["update_user_name"] == STAMP_SELECTION
|
||||||
|
assert "网关 502" not in reason
|
||||||
|
assert "secret-traceback" not in reason
|
||||||
|
assert len(repo.shift_errors) == 1
|
||||||
|
assert repo.shift_errors[0].error_code == "QUERY_FAILED"
|
||||||
|
assert "网关 502" in repo.shift_errors[0].error_text
|
||||||
|
assert "secret-traceback" in repo.shift_errors[0].error_text
|
||||||
|
|
||||||
|
|
||||||
|
# 加车抛错时原因列只写 ADD_FAILED,不含异常原文。
|
||||||
|
def test_job_add_error_writes_short_code_not_exception():
|
||||||
|
repo = MemoryCartRepo([_row()])
|
||||||
|
|
||||||
|
# 广东仓有货。
|
||||||
|
def search_yaoex(bar_code: str):
|
||||||
|
return [_offer(GUANGDONG)]
|
||||||
|
|
||||||
|
# 抛带 ORA 原文的加车错。
|
||||||
|
def add_yaoex(offer, qty: int):
|
||||||
|
raise RuntimeError("ORA-03114 secret-traceback")
|
||||||
|
|
||||||
|
summary = run_cart_batch(
|
||||||
|
repo,
|
||||||
|
searchers={YAOEX: search_yaoex},
|
||||||
|
adders={YAOEX: add_yaoex},
|
||||||
|
login_errors={},
|
||||||
|
)
|
||||||
|
assert summary.failed == 1
|
||||||
|
reason = repo.rows[1]["fail_reason"]
|
||||||
|
assert reason == "系统故障:ADD_FAILED"
|
||||||
|
assert "ORA-03114" not in reason
|
||||||
|
assert "secret-traceback" not in reason
|
||||||
|
assert len(repo.shift_errors) == 1
|
||||||
|
assert repo.shift_errors[0].error_code == "ADD_FAILED"
|
||||||
|
assert "ORA-03114" in repo.shift_errors[0].error_text
|
||||||
|
|
||||||
|
|
||||||
|
class HoldingCartRepo(MemoryCartRepo):
|
||||||
|
"""内存仓库加上可计数的 session,用来断言商城调用期间连接已归还。"""
|
||||||
|
|
||||||
|
def __init__(self, rows):
|
||||||
|
"""在内存行上加 held 计数。"""
|
||||||
|
super().__init__(rows)
|
||||||
|
self.held = 0
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session(self):
|
||||||
|
"""借连接时 held 加一,退出时减回。"""
|
||||||
|
self.held += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.held -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_mall_runs_after_connection_is_returned():
|
||||||
|
"""两组加车时连接已归还,盖章时才再借。"""
|
||||||
|
repo = HoldingCartRepo(
|
||||||
|
[
|
||||||
|
_row(fid=1, oms_id=2026100201),
|
||||||
|
_row(fid=2, oms_id=2026100202),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
during_add: list[int] = []
|
||||||
|
during_mark: list[int] = []
|
||||||
|
original_claim = repo.claim_pending
|
||||||
|
original_success = repo.mark_success
|
||||||
|
original_errors = repo.save_shift_errors
|
||||||
|
|
||||||
|
def claim_pending(limit=None, platform_types=None, **kwargs):
|
||||||
|
"""领取时必须占着本班的连接。"""
|
||||||
|
assert repo.held == 1
|
||||||
|
return original_claim(limit=limit, platform_types=platform_types, **kwargs)
|
||||||
|
|
||||||
|
def mark_success(fid):
|
||||||
|
"""盖章时必须占着本班的连接。"""
|
||||||
|
during_mark.append(repo.held)
|
||||||
|
return original_success(fid)
|
||||||
|
|
||||||
|
def save_shift_errors(items):
|
||||||
|
"""审计走连接池,不能在 session 里写。"""
|
||||||
|
assert repo.held == 0
|
||||||
|
return original_errors(items)
|
||||||
|
|
||||||
|
def search_yaoex(_bar_code: str):
|
||||||
|
"""返回能对上仓的候选。"""
|
||||||
|
return [_offer(GUANGDONG)]
|
||||||
|
|
||||||
|
def add_yaoex(_offer, _qty: int):
|
||||||
|
"""加车时连接必须已经归还。"""
|
||||||
|
during_add.append(repo.held)
|
||||||
|
return AddOutcome(kind=KIND_OK)
|
||||||
|
|
||||||
|
repo.claim_pending = claim_pending
|
||||||
|
repo.mark_success = mark_success
|
||||||
|
repo.save_shift_errors = save_shift_errors
|
||||||
|
summary = run_cart_batch(
|
||||||
|
repo,
|
||||||
|
searchers={YAOEX: search_yaoex},
|
||||||
|
adders={YAOEX: add_yaoex},
|
||||||
|
login_errors={},
|
||||||
|
)
|
||||||
|
assert during_add == [0, 0]
|
||||||
|
assert during_mark == [1, 1]
|
||||||
|
assert repo.held == 0
|
||||||
|
assert summary.added == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_skips_add_when_cart_already_has_the_line():
|
||||||
|
"""对方购物车已有不少于本行的数量、本地仍是加购中时,重跑不加第二份。"""
|
||||||
|
repo = MemoryCartRepo(
|
||||||
|
[
|
||||||
|
_row(
|
||||||
|
update_user_name=STAMP_CLAIMING,
|
||||||
|
update_time=datetime.now() - timedelta(minutes=31),
|
||||||
|
purchase_qty=7,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
added: list[int] = []
|
||||||
|
|
||||||
|
def search_yaoex(_bar_code: str):
|
||||||
|
"""购物车已够时不应再搜。"""
|
||||||
|
raise AssertionError("购物车已够,不应再搜")
|
||||||
|
|
||||||
|
def add_yaoex(_offer, qty: int):
|
||||||
|
"""购物车已够时不应再加。"""
|
||||||
|
added.append(qty)
|
||||||
|
return AddOutcome(kind=KIND_OK)
|
||||||
|
|
||||||
|
def cart_qty(bar_code: str, supplier: str) -> int:
|
||||||
|
"""这一仓比本行多。多出来的数量不当成另一张单。"""
|
||||||
|
assert bar_code == "06901234567890"
|
||||||
|
assert supplier == GUANGDONG
|
||||||
|
return 10
|
||||||
|
|
||||||
|
summary = run_cart_batch(
|
||||||
|
repo,
|
||||||
|
searchers={YAOEX: search_yaoex},
|
||||||
|
adders={YAOEX: add_yaoex},
|
||||||
|
login_errors={},
|
||||||
|
cart_readers={YAOEX: cart_qty},
|
||||||
|
)
|
||||||
|
assert added == []
|
||||||
|
assert summary.added == 1
|
||||||
|
assert summary.failed == 0
|
||||||
|
assert repo.rows[1]["status"] == 1
|
||||||
|
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
||||||
|
assert repo.rows[1]["fail_reason"] is None
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from cart_match import CartOffer, match_offer, middle_pack, qty_fits_pack
|
from cart_match import CartOffer, match_offer
|
||||||
|
|
||||||
|
|
||||||
def _offer(**overrides) -> CartOffer:
|
def _offer(**overrides) -> CartOffer:
|
||||||
|
|
@ -46,11 +46,3 @@ def test_same_supplier_keeps_cheaper():
|
||||||
|
|
||||||
def test_missing_supplier_returns_none():
|
def test_missing_supplier_returns_none():
|
||||||
assert match_offer([_offer()], "杭州小药药自营旗舰店") is None
|
assert match_offer([_offer()], "杭州小药药自营旗舰店") is None
|
||||||
|
|
||||||
|
|
||||||
def test_qty_not_multiple_of_middle_pack():
|
|
||||||
offer = _offer(extra={"raw": {"middlePackNum": 10}})
|
|
||||||
assert middle_pack(offer) == 10
|
|
||||||
assert qty_fits_pack(7, 10) is False
|
|
||||||
assert qty_fits_pack(10, 10) is True
|
|
||||||
assert qty_fits_pack(7, None) is True
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,28 @@
|
||||||
|
# 加购仓库:领取戳、租约、假连接录 SQL。不连真库。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rpa_oracle import RepoUnavailable, load_job_env
|
||||||
from cart_repo import (
|
from cart_repo import (
|
||||||
|
LEASE_MINUTES,
|
||||||
STAMP_CLAIMING,
|
STAMP_CLAIMING,
|
||||||
STAMP_DONE,
|
STAMP_DONE,
|
||||||
STAMP_SELECTION,
|
STAMP_SELECTION,
|
||||||
MemoryCartRepo,
|
MemoryCartRepo,
|
||||||
OracleCartRepo,
|
OracleCartRepo,
|
||||||
|
_pending_select_sql,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条待加购表 2 行。
|
||||||
def _row(**overrides) -> dict:
|
def _row(**overrides) -> dict:
|
||||||
data = {
|
data = {
|
||||||
"fid": 1,
|
"fid": 1,
|
||||||
|
|
@ -25,6 +41,7 @@ def _row(**overrides) -> dict:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# 系统管理员成功行领走盖成加购中,已完成和失败行不领。
|
||||||
def test_claim_pending_stamps_processing_and_skips_done():
|
def test_claim_pending_stamps_processing_and_skips_done():
|
||||||
repo = MemoryCartRepo(
|
repo = MemoryCartRepo(
|
||||||
[
|
[
|
||||||
|
|
@ -42,6 +59,29 @@ def test_claim_pending_stamps_processing_and_skips_done():
|
||||||
assert repo.rows[3]["status"] == 2
|
assert repo.rows[3]["status"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# 戳加购中且 update_time 超过租约的行可以再领。
|
||||||
|
def test_claim_reclaims_expired_claiming_stamp():
|
||||||
|
now = datetime(2026, 10, 22, 12, 0, 0)
|
||||||
|
repo = MemoryCartRepo(
|
||||||
|
[_row(update_user_name=STAMP_CLAIMING, update_time=now - timedelta(minutes=31))]
|
||||||
|
)
|
||||||
|
claimed = repo.claim_pending(now=now)
|
||||||
|
assert [row.fid for row in claimed] == [1]
|
||||||
|
assert repo.rows[1]["update_user_name"] == STAMP_CLAIMING
|
||||||
|
|
||||||
|
|
||||||
|
# 戳加购中且未过租约的行不领。
|
||||||
|
def test_claim_skips_fresh_claiming_stamp():
|
||||||
|
now = datetime(2026, 10, 22, 12, 0, 0)
|
||||||
|
repo = MemoryCartRepo(
|
||||||
|
[_row(update_user_name=STAMP_CLAIMING, update_time=now - timedelta(minutes=1))]
|
||||||
|
)
|
||||||
|
claimed = repo.claim_pending(now=now)
|
||||||
|
assert claimed == []
|
||||||
|
assert repo.rows[1]["update_user_name"] == STAMP_CLAIMING
|
||||||
|
|
||||||
|
|
||||||
|
# 成功只盖加购班,不插行、不改 status。
|
||||||
def test_mark_success_does_not_insert_or_change_status():
|
def test_mark_success_does_not_insert_or_change_status():
|
||||||
repo = MemoryCartRepo([_row()])
|
repo = MemoryCartRepo([_row()])
|
||||||
repo.claim_pending()
|
repo.claim_pending()
|
||||||
|
|
@ -53,6 +93,7 @@ def test_mark_success_does_not_insert_or_change_status():
|
||||||
assert repo.rows[1]["push_status"] == 0
|
assert repo.rows[1]["push_status"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# 失败改 status=2 并写下原因。
|
||||||
def test_mark_fail_sets_status_and_reason():
|
def test_mark_fail_sets_status_and_reason():
|
||||||
repo = MemoryCartRepo([_row()])
|
repo = MemoryCartRepo([_row()])
|
||||||
repo.claim_pending()
|
repo.claim_pending()
|
||||||
|
|
@ -63,26 +104,103 @@ def test_mark_fail_sets_status_and_reason():
|
||||||
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
||||||
|
|
||||||
|
|
||||||
def test_oracle_claim_updates_stamp():
|
# 加购 .env 空缺时补选品和 oms .env,已有非空环境变量不覆盖。
|
||||||
fetched = [
|
def test_load_runtime_env_fills_blank_from_other_env(tmp_path, monkeypatch):
|
||||||
(
|
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
||||||
11,
|
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
||||||
2026100201,
|
monkeypatch.setenv("RPA_ORACLE_USER", "keep-me")
|
||||||
66,
|
cart_env = tmp_path / "cart.env"
|
||||||
"广东壹号药业有限公司",
|
select_env = tmp_path / "select.env"
|
||||||
"0690",
|
oms_env = tmp_path / "oms.env"
|
||||||
7,
|
cart_env.write_text("RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\n", encoding="utf-8")
|
||||||
1,
|
select_env.write_text("RPA_ORACLE_PASSWORD=from-select\nRPA_ORACLE_USER=from-select\n", encoding="utf-8")
|
||||||
None,
|
oms_env.write_text("RPA_ORACLE_PASSWORD=from-oms\n", encoding="utf-8")
|
||||||
0,
|
load_job_env(tmp_path, [cart_env, select_env, oms_env])
|
||||||
STAMP_SELECTION,
|
assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
|
||||||
1,
|
assert os.environ["RPA_ORACLE_PASSWORD"] == "from-select"
|
||||||
)
|
assert os.environ["RPA_ORACLE_USER"] == "keep-me"
|
||||||
]
|
|
||||||
executed: list[tuple[str, dict]] = []
|
|
||||||
|
|
||||||
class FakeCursor:
|
|
||||||
description = [
|
# 缺 HOST/用户/密码时 from_env 抛数据库不可用。
|
||||||
|
def test_from_env_without_user_raises(monkeypatch):
|
||||||
|
monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
|
||||||
|
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
||||||
|
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
||||||
|
with pytest.raises(RepoUnavailable):
|
||||||
|
OracleCartRepo.from_env()
|
||||||
|
|
||||||
|
|
||||||
|
# 录 SQL 的假游标,fetchall 返回预设行。
|
||||||
|
class _FakeCursor:
|
||||||
|
# 挂上共享的 executed 列表和预设查询结果。
|
||||||
|
def __init__(self, executed: list, rows: list | None = None, description=None):
|
||||||
|
self._executed = executed
|
||||||
|
self._rows = rows or []
|
||||||
|
self.description = description
|
||||||
|
self.rowcount = 1
|
||||||
|
|
||||||
|
# 进入 with,返回自身。
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
# 退出 with,不吞异常。
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 记下 SQL 和 bind。一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
params = params or {}
|
||||||
|
_reject_oversized_in(params)
|
||||||
|
self._executed.append((sql, params))
|
||||||
|
|
||||||
|
# 返回预设行的副本。
|
||||||
|
def fetchall(self):
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
# 假连接:同一实例上的 cursor 共享 executed 列表。
|
||||||
|
class _FakeConn:
|
||||||
|
# 可选带上领取查询要返回的行。
|
||||||
|
def __init__(self, rows: list | None = None, description=None):
|
||||||
|
self.executed: list[tuple[str, dict]] = []
|
||||||
|
self.committed = False
|
||||||
|
self.commits = 0
|
||||||
|
self._rows = rows or []
|
||||||
|
self._description = description
|
||||||
|
|
||||||
|
# 新开一个录 SQL 的游标。
|
||||||
|
def cursor(self):
|
||||||
|
return _FakeCursor(self.executed, self._rows, self._description)
|
||||||
|
|
||||||
|
# 记下已提交。
|
||||||
|
def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
# 本套用例不应回滚。
|
||||||
|
def rollback(self):
|
||||||
|
raise AssertionError("不应 rollback")
|
||||||
|
|
||||||
|
# 归还时由池调用,假连接不计数。
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 只交出一条连接,并记下借了几次。
|
||||||
|
class _FakePool:
|
||||||
|
# 挂上要借出的那条假连接。
|
||||||
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
self.acquires = 0
|
||||||
|
|
||||||
|
# 借出连接并给 acquires 加一。
|
||||||
|
@contextmanager
|
||||||
|
def acquire(self):
|
||||||
|
self.acquires += 1
|
||||||
|
yield self._conn
|
||||||
|
|
||||||
|
|
||||||
|
_CLAIM_DESCRIPTION = (
|
||||||
("FID",),
|
("FID",),
|
||||||
("OMS_ID",),
|
("OMS_ID",),
|
||||||
("PLATFORM_TYPE",),
|
("PLATFORM_TYPE",),
|
||||||
|
|
@ -94,43 +212,126 @@ def test_oracle_claim_updates_stamp():
|
||||||
("PUSH_STATUS",),
|
("PUSH_STATUS",),
|
||||||
("UPDATE_USER_NAME",),
|
("UPDATE_USER_NAME",),
|
||||||
("OPERATE_TYPE",),
|
("OPERATE_TYPE",),
|
||||||
]
|
)
|
||||||
rowcount = 1
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
# 构造领取查询返回的一行元组。
|
||||||
return False
|
def _fetched_row(fid: int = 11):
|
||||||
|
return (
|
||||||
|
fid,
|
||||||
|
2026100201,
|
||||||
|
66,
|
||||||
|
"广东壹号药业有限公司",
|
||||||
|
"0690",
|
||||||
|
7,
|
||||||
|
1,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
STAMP_SELECTION,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
executed.append((sql, params or {}))
|
|
||||||
|
|
||||||
def fetchall(self):
|
# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期加购中可再领。
|
||||||
return fetched
|
def test_claim_sql_uses_skip_locked_and_lease():
|
||||||
|
sql, params = _pending_select_sql(limit=None, platform_types=None)
|
||||||
|
text = " ".join(sql.split()).lower()
|
||||||
|
assert "for update skip locked" in text
|
||||||
|
assert "numtodsinterval(:lease_minutes" in text
|
||||||
|
assert params["lease_minutes"] == LEASE_MINUTES
|
||||||
|
assert LEASE_MINUTES == 30
|
||||||
|
limited, limited_params = _pending_select_sql(limit=5, platform_types=None)
|
||||||
|
assert "skip locked" in " ".join(limited.split()).lower()
|
||||||
|
assert limited_params["limit"] == 5
|
||||||
|
|
||||||
class FakeConn:
|
|
||||||
committed = False
|
|
||||||
|
|
||||||
def cursor(self):
|
# 领取一次借连接:SKIP LOCKED 选出后盖成加购中。
|
||||||
return FakeCursor()
|
def test_oracle_claim_updates_stamp():
|
||||||
|
conn = _FakeConn(rows=[_fetched_row()], description=_CLAIM_DESCRIPTION)
|
||||||
def commit(self):
|
pool = _FakePool(conn)
|
||||||
self.committed = True
|
repo = OracleCartRepo(pool)
|
||||||
|
|
||||||
def rollback(self):
|
|
||||||
raise AssertionError("不应 rollback")
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
conn = FakeConn()
|
|
||||||
repo = OracleCartRepo(connect=lambda: conn)
|
|
||||||
claimed = repo.claim_pending()
|
claimed = repo.claim_pending()
|
||||||
|
assert pool.acquires == 1
|
||||||
assert conn.committed is True
|
assert conn.committed is True
|
||||||
assert len(claimed) == 1
|
assert len(claimed) == 1
|
||||||
assert claimed[0].fid == 11
|
assert claimed[0].fid == 11
|
||||||
assert claimed[0].update_user_name == STAMP_CLAIMING
|
assert claimed[0].update_user_name == STAMP_CLAIMING
|
||||||
assert "update_user_name" in executed[1][0].lower()
|
select_sql, select_binds = conn.executed[0]
|
||||||
assert executed[1][1]["fid"] == 11
|
assert "skip locked" in select_sql.lower()
|
||||||
assert executed[1][1]["stamp"] == STAMP_CLAIMING
|
assert select_binds["lease_minutes"] == 30
|
||||||
|
update_sql = " ".join(conn.executed[1][0].split()).lower()
|
||||||
|
assert "update_user_name" in update_sql
|
||||||
|
assert conn.executed[1][1]["stamp"] == STAMP_CLAIMING
|
||||||
|
assert 11 in conn.executed[1][1].values()
|
||||||
|
|
||||||
|
|
||||||
|
# 班次 session 内领取加两行盖章只从池里借一次连接。
|
||||||
|
def test_session_claim_and_marks_acquire_once():
|
||||||
|
conn = _FakeConn(
|
||||||
|
rows=[_fetched_row(11), _fetched_row(12)],
|
||||||
|
description=_CLAIM_DESCRIPTION,
|
||||||
|
)
|
||||||
|
pool = _FakePool(conn)
|
||||||
|
repo = OracleCartRepo(pool)
|
||||||
|
with repo.session():
|
||||||
|
claimed = repo.claim_pending()
|
||||||
|
assert [row.fid for row in claimed] == [11, 12]
|
||||||
|
repo.mark_success(11)
|
||||||
|
repo.mark_fail(12, "库存不足")
|
||||||
|
assert pool.acquires == 1
|
||||||
|
assert conn.commits >= 3
|
||||||
|
mark_sqls = [item[0].lower() for item in conn.executed[2:]]
|
||||||
|
assert any("fail_reason" in sql for sql in mark_sqls)
|
||||||
|
|
||||||
|
|
||||||
|
def _fid_bind_count(params: dict) -> int:
|
||||||
|
"""数出本段 bind 里的 fid 个数。"""
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for key in params
|
||||||
|
if isinstance(key, str) and key.startswith("id") and key[2:].isdigit()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_oversized_in(params: dict) -> None:
|
||||||
|
"""一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。"""
|
||||||
|
if _fid_bind_count(params) > 1000:
|
||||||
|
raise RuntimeError("ORA-01795")
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_1001_rows_updates_first_chunk_without_unavailable():
|
||||||
|
"""领取 1001 条时第一段 500 能领走,同一事务提交,不报数据库不可用。"""
|
||||||
|
conn = _FakeConn(
|
||||||
|
rows=[_fetched_row(fid) for fid in range(1, 1002)],
|
||||||
|
description=_CLAIM_DESCRIPTION,
|
||||||
|
)
|
||||||
|
pool = _FakePool(conn)
|
||||||
|
repo = OracleCartRepo(pool)
|
||||||
|
claimed = repo.claim_pending()
|
||||||
|
assert pool.acquires == 1
|
||||||
|
assert len(claimed) == 1001
|
||||||
|
assert claimed[0].update_user_name == STAMP_CLAIMING
|
||||||
|
updates = [
|
||||||
|
item for item in conn.executed if item[0].lstrip().lower().startswith("update")
|
||||||
|
]
|
||||||
|
assert [_fid_bind_count(params) for _, params in updates] == [500, 500, 1]
|
||||||
|
assert updates[0][1]["id0"] == 1
|
||||||
|
assert updates[0][1]["stamp"] == STAMP_CLAIMING
|
||||||
|
assert conn.committed is True
|
||||||
|
|
||||||
|
|
||||||
|
# 登录和搜索不再往 sys.path 贴选品。
|
||||||
|
def test_login_and_search_do_not_append_sys_path():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
for name in ("yaoex_login.py", "cart_search.py"):
|
||||||
|
tree = ast.parse((root / name).read_text(encoding="utf-8"))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.func
|
||||||
|
if isinstance(func, ast.Attribute) and func.attr in {"append", "insert"}:
|
||||||
|
value = func.value
|
||||||
|
if isinstance(value, ast.Attribute) and value.attr == "path":
|
||||||
|
raise AssertionError(f"{name} 仍在改 sys.path")
|
||||||
|
if isinstance(value, ast.Name) and value.id == "path":
|
||||||
|
raise AssertionError(f"{name} 仍在改 path")
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,103 @@
|
||||||
from main import parse_args
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import cart_job
|
||||||
|
import main
|
||||||
|
from cart_repo import STAMP_SELECTION, MemoryCartRepo
|
||||||
|
from main import DEFAULT_CART_LIMIT, parse_args, pay_export_path
|
||||||
|
|
||||||
|
|
||||||
def test_parse_args_defaults():
|
def test_parse_args_defaults():
|
||||||
args = parse_args([])
|
args = parse_args([])
|
||||||
assert args.days == 0
|
|
||||||
assert args.dry_run is False
|
|
||||||
assert args.xls is None
|
|
||||||
assert args.command == "pay"
|
assert args.command == "pay"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_args_xls_dry_run():
|
def test_parse_args_pay_rejects_mode_switches():
|
||||||
args = parse_args(["--xls", "/tmp/orders.xls", "--dry-run"])
|
with pytest.raises(SystemExit):
|
||||||
assert args.dry_run is True
|
parse_args(["--xls", "/tmp/orders.xls", "--dry-run"])
|
||||||
assert str(args.xls).endswith("orders.xls")
|
|
||||||
assert args.command == "pay"
|
|
||||||
|
def test_pay_export_path_groups_by_order_day(tmp_path):
|
||||||
|
when = datetime(2026, 10, 24, 15, 30, 45)
|
||||||
|
path = pay_export_path(
|
||||||
|
"2026-10-24 00:00:00",
|
||||||
|
"2026-10-24 23:59:59",
|
||||||
|
when,
|
||||||
|
root=tmp_path,
|
||||||
|
)
|
||||||
|
assert path == tmp_path / "2026-10-24" / "153045.xls"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pay_export_path_keeps_same_second_export(tmp_path):
|
||||||
|
when = datetime(2026, 10, 24, 15, 30, 45)
|
||||||
|
first = pay_export_path(
|
||||||
|
"2026-10-24 00:00:00",
|
||||||
|
"2026-10-24 23:59:59",
|
||||||
|
when,
|
||||||
|
root=tmp_path,
|
||||||
|
)
|
||||||
|
first.parent.mkdir(parents=True)
|
||||||
|
first.write_bytes(b"a")
|
||||||
|
second = pay_export_path(
|
||||||
|
"2026-10-24 00:00:00",
|
||||||
|
"2026-10-24 23:59:59",
|
||||||
|
when,
|
||||||
|
root=tmp_path,
|
||||||
|
)
|
||||||
|
assert second == tmp_path / "2026-10-24" / "153045_2.xls"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_args_cart_subcommand():
|
def test_parse_args_cart_subcommand():
|
||||||
|
"""显式 --limit 仍覆盖默认 20。"""
|
||||||
args = parse_args(["cart", "--limit", "5", "--platform", "yaoex"])
|
args = parse_args(["cart", "--limit", "5", "--platform", "yaoex"])
|
||||||
assert args.command == "cart"
|
assert args.command == "cart"
|
||||||
assert args.limit == 5
|
assert args.limit == 5
|
||||||
assert args.platform == "yaoex"
|
assert args.platform == "yaoex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cart_default_limit_caps_claim(monkeypatch):
|
||||||
|
"""不传 --limit 时加购最多领 20 行。"""
|
||||||
|
import main
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"fid": i,
|
||||||
|
"oms_id": 2026100200 + i,
|
||||||
|
"platform_type": 66,
|
||||||
|
"supplier_name": "广东壹号药业有限公司",
|
||||||
|
"bar_code": "06901234567890",
|
||||||
|
"purchase_qty": 1,
|
||||||
|
"status": 1,
|
||||||
|
"fail_reason": None,
|
||||||
|
"push_status": 0,
|
||||||
|
"update_user_name": STAMP_SELECTION,
|
||||||
|
"operate_type": 1,
|
||||||
|
}
|
||||||
|
for i in range(1, 31)
|
||||||
|
]
|
||||||
|
repo = MemoryCartRepo(rows)
|
||||||
|
seen: dict[str, int | None] = {}
|
||||||
|
|
||||||
|
def fake_run_cart_batch(bound_repo, **kwargs):
|
||||||
|
"""只按入口传入的上限领取,不加车。"""
|
||||||
|
seen["limit"] = kwargs.get("limit")
|
||||||
|
claimed = bound_repo.claim_pending(
|
||||||
|
limit=kwargs.get("limit"),
|
||||||
|
platform_types=kwargs.get("platform_types"),
|
||||||
|
)
|
||||||
|
seen["count"] = len(claimed)
|
||||||
|
return SimpleNamespace(claimed=len(claimed), added=0, failed=0)
|
||||||
|
|
||||||
|
def no_browser(**_kwargs):
|
||||||
|
"""挡住商城登录。"""
|
||||||
|
raise RuntimeError("no browser")
|
||||||
|
|
||||||
|
monkeypatch.setattr(cart_job, "run_cart_batch", fake_run_cart_batch)
|
||||||
|
monkeypatch.setattr(main, "resolve_yaoex_auth", no_browser)
|
||||||
|
monkeypatch.setattr(main, "resolve_ybm_cookies", no_browser)
|
||||||
|
main.main(["cart"], repo=repo)
|
||||||
|
assert seen["limit"] == DEFAULT_CART_LIMIT
|
||||||
|
assert seen["count"] == 20
|
||||||
|
assert seen["count"] <= DEFAULT_CART_LIMIT
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,65 @@
|
||||||
|
# 支付落表仓库:假连接录 SQL。不连真库。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from repo import OraclePayRepo
|
from repo import OraclePayRepo
|
||||||
|
|
||||||
|
|
||||||
def test_save_pay_rows_inserts_operate_type_2():
|
# 只交出一条连接。
|
||||||
executed: list[tuple[str, dict]] = []
|
class _FakePool:
|
||||||
|
# 挂上要借出的那条假连接。
|
||||||
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
|
||||||
|
# 借出连接。
|
||||||
|
@contextmanager
|
||||||
|
def acquire(self):
|
||||||
|
yield self._conn
|
||||||
|
|
||||||
|
|
||||||
|
# 两行支付一次 executemany,带 operate_type=2 和订单号。
|
||||||
|
def test_save_pay_rows_inserts_operate_type_2():
|
||||||
|
many: list[tuple[str, list]] = []
|
||||||
|
|
||||||
|
# 录 SQL 的假游标。
|
||||||
class FakeCursor:
|
class FakeCursor:
|
||||||
|
# 进入 with,返回自身。
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
# 退出 with,不吞异常。
|
||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
# 记下一次批量 SQL 和全部 bind。
|
||||||
executed.append((sql, params or {}))
|
|
||||||
|
|
||||||
def executemany(self, sql, params):
|
def executemany(self, sql, params):
|
||||||
for item in params:
|
many.append((sql, [dict(item) for item in params]))
|
||||||
executed.append((sql, item))
|
|
||||||
|
|
||||||
|
# 提交后记下 committed。
|
||||||
class FakeConn:
|
class FakeConn:
|
||||||
committed = False
|
committed = False
|
||||||
|
|
||||||
|
# 新开录 SQL 的游标。
|
||||||
def cursor(self):
|
def cursor(self):
|
||||||
return FakeCursor()
|
return FakeCursor()
|
||||||
|
|
||||||
|
# 记下已提交。
|
||||||
def commit(self):
|
def commit(self):
|
||||||
self.committed = True
|
self.committed = True
|
||||||
|
|
||||||
|
# 本套用例不应回滚。
|
||||||
def rollback(self):
|
def rollback(self):
|
||||||
raise AssertionError("不应 rollback")
|
raise AssertionError("不应 rollback")
|
||||||
|
|
||||||
|
# 假连接不计数。
|
||||||
def close(self):
|
def close(self):
|
||||||
pass
|
return None
|
||||||
|
|
||||||
conn = FakeConn()
|
conn = FakeConn()
|
||||||
repo = OraclePayRepo(connect=lambda: conn)
|
repo = OraclePayRepo(_FakePool(conn))
|
||||||
repo.save_pay_rows(
|
row = {
|
||||||
[
|
|
||||||
{
|
|
||||||
"operate_type": 2,
|
"operate_type": 2,
|
||||||
"oms_id": 0,
|
"oms_id": 0,
|
||||||
"platform_type": 66,
|
"platform_type": 66,
|
||||||
|
|
@ -54,38 +76,55 @@ def test_save_pay_rows_inserts_operate_type_2():
|
||||||
"org_code": None,
|
"org_code": None,
|
||||||
"multi_platform_flag": 0,
|
"multi_platform_flag": 0,
|
||||||
}
|
}
|
||||||
]
|
second = dict(row)
|
||||||
)
|
second["pay_order_no"] = "ZQD2"
|
||||||
|
second["pay_order_dtl_id"] = "b1b2c3d4e5f6789012345678901234ab"
|
||||||
|
repo.save_pay_rows([row, second])
|
||||||
assert conn.committed is True
|
assert conn.committed is True
|
||||||
assert "rpa_cart_settlement_goods_data" in executed[0][0].lower()
|
assert len(many) == 1
|
||||||
assert executed[0][1]["operate_type"] == 2
|
sql, binds = many[0]
|
||||||
assert executed[0][1]["oms_id"] == 0
|
assert "rpa_cart_settlement_goods_data" in sql.lower()
|
||||||
assert executed[0][1]["pay_order_no"] == "ZQD1"
|
assert [item["operate_type"] for item in binds] == [2, 2]
|
||||||
assert executed[0][1]["pay_order_dtl_id"] == "a1b2c3d4e5f6789012345678901234ab"
|
assert [item["oms_id"] for item in binds] == [0, 0]
|
||||||
|
assert [item["pay_order_no"] for item in binds] == ["ZQD1", "ZQD2"]
|
||||||
|
assert [item["pay_order_dtl_id"] for item in binds] == [
|
||||||
|
"a1b2c3d4e5f6789012345678901234ab",
|
||||||
|
"b1b2c3d4e5f6789012345678901234ab",
|
||||||
|
]
|
||||||
|
assert [item["create_user_name"] for item in binds] == ["rpa-cart", "rpa-cart"]
|
||||||
|
|
||||||
|
|
||||||
|
# 已有支付单号去重时丢掉空值。
|
||||||
def test_load_existing_pay_order_nos():
|
def test_load_existing_pay_order_nos():
|
||||||
|
# 返回预设支付单号。
|
||||||
class FakeCursor:
|
class FakeCursor:
|
||||||
|
# 进入 with,返回自身。
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
# 退出 with,不吞异常。
|
||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 记下 SQL 和 bind。
|
||||||
def execute(self, sql, params=None):
|
def execute(self, sql, params=None):
|
||||||
self.sql = sql
|
self.sql = sql
|
||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
|
# 返回含重复和空值的单号。
|
||||||
def fetchall(self):
|
def fetchall(self):
|
||||||
return [("ZQD1",), ("ZQD1",), (None,)]
|
return [("ZQD1",), ("ZQD1",), (None,)]
|
||||||
|
|
||||||
|
# 只提供游标。
|
||||||
class FakeConn:
|
class FakeConn:
|
||||||
|
# 新开返回预设单号的游标。
|
||||||
def cursor(self):
|
def cursor(self):
|
||||||
return FakeCursor()
|
return FakeCursor()
|
||||||
|
|
||||||
|
# 假连接不计数。
|
||||||
def close(self):
|
def close(self):
|
||||||
pass
|
return None
|
||||||
|
|
||||||
repo = OraclePayRepo(connect=lambda: FakeConn())
|
repo = OraclePayRepo(_FakePool(FakeConn()))
|
||||||
keys = repo.load_existing_pay_order_nos()
|
keys = repo.load_existing_pay_order_nos()
|
||||||
assert keys == {"ZQD1"}
|
assert keys == {"ZQD1"}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""1 号药城订单导出。"""
|
# 1 号药城订单导出。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -15,6 +16,7 @@ GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
|
||||||
EXPORT_URL = f"{GATEWAY}/api/order/export"
|
EXPORT_URL = f"{GATEWAY}/api/order/export"
|
||||||
|
|
||||||
|
|
||||||
|
# 导出用的起止时间。默认只含当天,从 0 点到 23:59:59。
|
||||||
def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]:
|
def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]:
|
||||||
end = day or date.today()
|
end = day or date.today()
|
||||||
start = end - timedelta(days=max(days, 0))
|
start = end - timedelta(days=max(days, 0))
|
||||||
|
|
@ -24,6 +26,7 @@ def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 按时间段导出 1 号药城订单明细,返回 Excel 字节。回包不是表格就失败。
|
||||||
def export_orders(
|
def export_orders(
|
||||||
token: str,
|
token: str,
|
||||||
*,
|
*,
|
||||||
|
|
@ -62,6 +65,7 @@ def export_orders(
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 拼 1 号药城导出表单,带上登录 token。
|
||||||
def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]:
|
def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]:
|
||||||
payload = {
|
payload = {
|
||||||
"traderName": "yaoex_pc",
|
"traderName": "yaoex_pc",
|
||||||
|
|
@ -77,6 +81,7 @@ def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]:
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
# 1 号药城订单导出请求头。来源页是订单列表。
|
||||||
def _headers() -> dict[str, str]:
|
def _headers() -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
"""复用 rpa_selection 的 1 号药城 / 药帮忙登录。"""
|
# 复用 rpa_selection 的 1 号药城 / 药帮忙登录。
|
||||||
|
#
|
||||||
|
# 导入路径由 selection_path 放进 rpa_selection,这里不再改 sys.path。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SELECTION_ROOT = Path(__file__).resolve().parent.parent / "rpa_selection"
|
import selection_path # noqa: F401 先把 rpa_selection 放进导入路径
|
||||||
if str(SELECTION_ROOT) not in sys.path:
|
from rpa_oracle import load_job_env
|
||||||
sys.path.append(str(SELECTION_ROOT))
|
from auth_login import (
|
||||||
|
|
||||||
from auth_login import ( # noqa: E402
|
|
||||||
DEFAULT_YBM_COOKIE_PATH,
|
DEFAULT_YBM_COOKIE_PATH,
|
||||||
DEFAULT_YAOEX_AUTH_PATH,
|
DEFAULT_YAOEX_AUTH_PATH,
|
||||||
BrowserRuntime,
|
BrowserRuntime,
|
||||||
load_cookie_file,
|
load_cookie_file,
|
||||||
load_dotenv,
|
|
||||||
load_secrets,
|
load_secrets,
|
||||||
load_yaoex_auth_file,
|
load_yaoex_auth_file,
|
||||||
login_yaoex,
|
login_yaoex,
|
||||||
|
|
@ -21,17 +20,17 @@ from auth_login import ( # noqa: E402
|
||||||
save_cookie_file,
|
save_cookie_file,
|
||||||
save_yaoex_auth,
|
save_yaoex_auth,
|
||||||
)
|
)
|
||||||
from search_yaoex import YaoexAuth # noqa: E402
|
from search_yaoex import YaoexAuth
|
||||||
|
|
||||||
|
|
||||||
|
# 读加购 / 选品 / oms 的 .env,不覆盖已有非空变量。
|
||||||
def load_cart_dotenv() -> None:
|
def load_cart_dotenv() -> None:
|
||||||
load_dotenv(Path(__file__).resolve().parent / ".env")
|
load_job_env(Path(__file__).resolve().parent)
|
||||||
load_dotenv(SELECTION_ROOT / ".env")
|
|
||||||
|
|
||||||
|
|
||||||
|
# 拿到 1 号药城登录态。有缓存且 token 有效则直接用,否则走浏览器登录。
|
||||||
def resolve_yaoex_auth(
|
def resolve_yaoex_auth(
|
||||||
*,
|
*,
|
||||||
secrets_path: Path | None = None,
|
|
||||||
auth_path: Path | None = None,
|
auth_path: Path | None = None,
|
||||||
fresh: bool = False,
|
fresh: bool = False,
|
||||||
runtime: BrowserRuntime | None = None,
|
runtime: BrowserRuntime | None = None,
|
||||||
|
|
@ -41,13 +40,15 @@ def resolve_yaoex_auth(
|
||||||
auth = load_yaoex_auth_file(cached)
|
auth = load_yaoex_auth_file(cached)
|
||||||
if auth.token and auth.token != "t":
|
if auth.token and auth.token != "t":
|
||||||
return auth
|
return auth
|
||||||
secrets = load_secrets(secrets_path)
|
secrets = load_secrets()
|
||||||
if not secrets["yaoex"].get("username"):
|
if not secrets["yaoex"].get("username"):
|
||||||
raise SystemExit("缺少 1 号药城账号。配置 rpa_selection/.env 或环境变量 YAOEX_USERNAME / YAOEX_PASSWORD。")
|
raise SystemExit(
|
||||||
|
"缺少 1 号药城账号。配置 rpa_selection/.env 或环境变量 YAOEX_USERNAME / YAOEX_PASSWORD。"
|
||||||
|
)
|
||||||
own = runtime is None
|
own = runtime is None
|
||||||
session = runtime or BrowserRuntime(keep=False)
|
session = runtime or BrowserRuntime(keep=False)
|
||||||
try:
|
try:
|
||||||
auth = login_yaoex(secrets_path, runtime=session)
|
auth = login_yaoex(runtime=session)
|
||||||
save_yaoex_auth(auth, cached)
|
save_yaoex_auth(auth, cached)
|
||||||
return auth
|
return auth
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -55,9 +56,9 @@ def resolve_yaoex_auth(
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 拿到药帮忙登录 cookie。有缓存则直接用,否则走浏览器登录。
|
||||||
def resolve_ybm_cookies(
|
def resolve_ybm_cookies(
|
||||||
*,
|
*,
|
||||||
secrets_path: Path | None = None,
|
|
||||||
cookie_path: Path | None = None,
|
cookie_path: Path | None = None,
|
||||||
fresh: bool = False,
|
fresh: bool = False,
|
||||||
runtime: BrowserRuntime | None = None,
|
runtime: BrowserRuntime | None = None,
|
||||||
|
|
@ -67,13 +68,15 @@ def resolve_ybm_cookies(
|
||||||
cookies = load_cookie_file(cached)
|
cookies = load_cookie_file(cached)
|
||||||
if cookies:
|
if cookies:
|
||||||
return cookies
|
return cookies
|
||||||
secrets = load_secrets(secrets_path)
|
secrets = load_secrets()
|
||||||
if not secrets["ybm"].get("username"):
|
if not secrets["ybm"].get("username"):
|
||||||
raise SystemExit("缺少药帮忙账号。配置 rpa_selection/.env 或环境变量 YBM_USERNAME / YBM_PASSWORD。")
|
raise SystemExit(
|
||||||
|
"缺少药帮忙账号。配置 rpa_selection/.env 或环境变量 YBM_USERNAME / YBM_PASSWORD。"
|
||||||
|
)
|
||||||
own = runtime is None
|
own = runtime is None
|
||||||
session = runtime or BrowserRuntime(keep=False)
|
session = runtime or BrowserRuntime(keep=False)
|
||||||
try:
|
try:
|
||||||
cookies = login_ybm(secrets_path, runtime=session)
|
cookies = login_ybm(runtime=session)
|
||||||
save_cookie_file(cookies, cached)
|
save_cookie_file(cookies, cached)
|
||||||
return cookies
|
return cookies
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -12,26 +12,28 @@
|
||||||
|
|
||||||
口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 4 节;合单细节见 `docs/合同系统录入流程.md`。
|
口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 4 节;合单细节见 `docs/合同系统录入流程.md`。
|
||||||
|
|
||||||
## 测试账号
|
## 测试环境
|
||||||
|
|
||||||
地址:`http://contract-test.gzmpc.com:8080/`
|
地址:`http://contract-test.gzmpc.com:8080/`
|
||||||
账号:`10007228 / 1`
|
账密只认环境变量 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD`,缺则入口失败。不要把密码写进仓库。
|
||||||
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
|
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
|
||||||
|
|
||||||
## 页面步骤
|
## 页面步骤
|
||||||
|
|
||||||
脚本直达起草/模板 URL:
|
脚本直达起草/模板 URL。修一步只打开对应文件;`contract_flow_steps.py` 是对外入口。
|
||||||
|
|
||||||
1. 打开合同系统并登录
|
1. 打开合同系统并登录(`contract_login.py` S02–S05)
|
||||||
2. 进起草,套非格式采购协议模板
|
2. 进起草,套非格式采购协议模板(直达 `FORM_PAGE_URL`)
|
||||||
3. 签约方:客户编码、客户名称
|
3. 签约方:客户编码、客户名称(`contract_form.py` S06)
|
||||||
4. 尽调:页面正文含「未尽调」则本单失败,不提交
|
4. 尽调:页面正文含「未尽调」则本单失败,不提交
|
||||||
5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同
|
5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同(S07)
|
||||||
6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`)
|
6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`)(S08)
|
||||||
7. 货品明细
|
7. 货品明细:弹窗按操作码搜,找不到则本单失败(`contract_goods.py` S09)
|
||||||
8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」
|
8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」(S10)
|
||||||
9. 点提交,不要点暂存
|
9. 挂合同正文(`contract_upload.py` S11);点提交(`contract_submit.py` S12),不要只点暂存
|
||||||
10. 从「我的合同」读正式合同号
|
10. 按 fdId 从详情读正式合同号(`contract_number.py` S13)
|
||||||
|
|
||||||
|
选择器、工具栏、`fdId` 在 `contract_common.py`。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
|
|
@ -52,6 +54,58 @@ uv run python run_batch.py
|
||||||
|
|
||||||
`submitMode=dry_run` 跑到提交前,不点提交。真起草用 `examples/contract_draft_yyc.json` / `examples/contract_draft_xyy.json`。
|
`submitMode=dry_run` 跑到提交前,不点提交。真起草用 `examples/contract_draft_yyc.json` / `examples/contract_draft_xyy.json`。
|
||||||
|
|
||||||
|
## 运维
|
||||||
|
|
||||||
|
### 怎么跑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rpa_contract
|
||||||
|
uv sync --python 3.12
|
||||||
|
uv run python run_batch.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Oracle 空缺时补 `rpa_oms/.env`。连接从 `rpa_oracle` 池取,班次默认池大小 1。缺 `RPA_ORACLE_HOST` / 用户 / 密码时入口失败,文案「数据库不可用」。合同系统账密用 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD`。未捕获异常原文进 `rpa_shift_error_audit`,Windows 上先执行 `docs/ddl/rpa_oms_staging_tables.sql` 里这张表。
|
||||||
|
|
||||||
|
JSON 干跑(不领表):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python main.py --input-json examples/contract_create_input_dry_run.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 看哪张表哪几列
|
||||||
|
|
||||||
|
- 表 3 `rpa_purchase_contract_data`:`process_status`(0 待领,3 处理中,1 成功,2 失败)、`process_time`、`process_result`、`supplier_id`、`contract_entry_status`、`contract_no`
|
||||||
|
- 表 4 `rpa_contract_agreement_result`:`action_code=1`、`contract_status`(1 起草成功,3 失败)、`contract_no`、`contract_fail_reason`、`push_status=0`
|
||||||
|
|
||||||
|
### 崩溃改回 SQL
|
||||||
|
|
||||||
|
停在 3 且未过 30 分钟租约:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE rpa_purchase_contract_data
|
||||||
|
SET process_status = 0
|
||||||
|
WHERE process_status = 3 AND fid = :fid;
|
||||||
|
```
|
||||||
|
|
||||||
|
过期的 3 下一班会再领,不必改。协议重录行(`contract_entry_status=0` 且已有 `contract_no`)本班不领。
|
||||||
|
|
||||||
|
### 原因列 vs 日志原文
|
||||||
|
|
||||||
|
表 3 `process_result` / 表 4 `contract_fail_reason`:尽调写「客户未尽调」,页面 NodeError 写业务文案,未捕获异常写 `PAGE_FAILED`。异常原文打本机日志,不进这两列。
|
||||||
|
|
||||||
|
### 点名命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rpa_contract
|
||||||
|
uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4 -q
|
||||||
|
uv run pytest tests/test_job.py::test_job_marks_diligence_fail -q
|
||||||
|
uv run pytest tests/test_oracle_repo.py::test_from_env_without_user_raises -q
|
||||||
|
uv run pytest tests/test_job.py::test_claim_reclaims_expired_status_3 tests/test_job.py::test_claim_skips_fresh_status_3 -q
|
||||||
|
uv run pytest tests/test_flow_gates.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
页面验收只看控制台 `ACCEPT SUMMARY` 或 `screenshots/` 下 `ACCEPT.txt`,禁止读 png。
|
||||||
|
|
||||||
## 本期不做
|
## 本期不做
|
||||||
|
|
||||||
返利班、向订单中心发 HTTP、改 `push_status`。
|
向订单中心发 HTTP、改 `push_status`。返利在 `rpa_rebate/`。点击顺序和选择器本段不改。
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
"""Shared ACCEPT report for every RPA flow (any platform / page).
|
# 页面验收报告。每步写一行 ACCEPT,汇总进 ACCEPT.txt。
|
||||||
|
#
|
||||||
|
# 验收看这份文本,不看截图。截图只在失败时留,给人查页面。
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -17,10 +15,14 @@ PACKAGE_ROOT = Path(__file__).resolve().parent
|
||||||
SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots"
|
SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots"
|
||||||
|
|
||||||
|
|
||||||
|
# 能按名字截图的浏览器。
|
||||||
class HasScreenshot(Protocol):
|
class HasScreenshot(Protocol):
|
||||||
def screenshot(self, name: str) -> str: ...
|
# 按名字截一张图,返回保存路径。
|
||||||
|
def screenshot(self, name: str) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# 验收的一步:步骤号、标题、是否通过、读到的事实、对不上的字段。
|
||||||
@dataclass
|
@dataclass
|
||||||
class AcceptLine:
|
class AcceptLine:
|
||||||
step: str
|
step: str
|
||||||
|
|
@ -30,23 +32,26 @@ class AcceptLine:
|
||||||
mismatches: list[str]
|
mismatches: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# 把流程名收成目录名。空名用 default,其余只留字母、数字、横线和底线。
|
||||||
def safe_flow_name(flow: str) -> str:
|
def safe_flow_name(flow: str) -> str:
|
||||||
text = (flow or "default").strip() or "default"
|
text = (flow or "default").strip() or "default"
|
||||||
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text)
|
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text)
|
||||||
|
|
||||||
|
|
||||||
|
# 这个流程的截图和 ACCEPT.txt 放在哪。
|
||||||
def artifact_dir(flow: str) -> Path:
|
def artifact_dir(flow: str) -> Path:
|
||||||
return SCREENSHOT_ROOT / safe_flow_name(flow)
|
return SCREENSHOT_ROOT / safe_flow_name(flow)
|
||||||
|
|
||||||
|
|
||||||
|
# 默认只在失败时截图。RPA_SCREENSHOT=always 时每步都留。
|
||||||
def evidence_shot(browser: HasScreenshot, name: str, *, failed: bool = False) -> str:
|
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()
|
mode = (os.environ.get("RPA_SCREENSHOT") or "fail").strip().lower()
|
||||||
if not failed and mode != "always":
|
if not failed and mode != "always":
|
||||||
return ""
|
return ""
|
||||||
return browser.screenshot(name)
|
return browser.screenshot(name)
|
||||||
|
|
||||||
|
|
||||||
|
# ACCEPT.txt 的路径。RPA_ACCEPT_LOG 可改到别处,单测用这个钉住文件。
|
||||||
def accept_log_path(flow: str = "default") -> Path:
|
def accept_log_path(flow: str = "default") -> Path:
|
||||||
override = os.environ.get("RPA_ACCEPT_LOG")
|
override = os.environ.get("RPA_ACCEPT_LOG")
|
||||||
if override:
|
if override:
|
||||||
|
|
@ -54,6 +59,7 @@ def accept_log_path(flow: str = "default") -> Path:
|
||||||
return artifact_dir(flow) / "ACCEPT.txt"
|
return artifact_dir(flow) / "ACCEPT.txt"
|
||||||
|
|
||||||
|
|
||||||
|
# 页面读数和期望是否同一值。空期望算通过;数字、是否、去空白后包含也算对上。
|
||||||
def field_match(actual: str, expected: str) -> bool:
|
def field_match(actual: str, expected: str) -> bool:
|
||||||
actual_text = (actual or "").strip()
|
actual_text = (actual or "").strip()
|
||||||
expected_text = (expected or "").strip()
|
expected_text = (expected or "").strip()
|
||||||
|
|
@ -78,6 +84,7 @@ def field_match(actual: str, expected: str) -> bool:
|
||||||
return compact_e in compact_a or compact_a in compact_e
|
return compact_e in compact_a or compact_a in compact_e
|
||||||
|
|
||||||
|
|
||||||
|
# 把一步收成控制台一行:ACCEPT、步骤、ok 或 FAIL、标题和读到的字段。
|
||||||
def format_accept(line: AcceptLine) -> str:
|
def format_accept(line: AcceptLine) -> str:
|
||||||
status = "ok" if line.ok else "FAIL"
|
status = "ok" if line.ok else "FAIL"
|
||||||
facts = " ".join(f"{key}={value}" for key, value in line.facts.items())
|
facts = " ".join(f"{key}={value}" for key, value in line.facts.items())
|
||||||
|
|
@ -85,16 +92,17 @@ def format_accept(line: AcceptLine) -> str:
|
||||||
return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip()
|
return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
# 一次页面流的验收单。每步打印并覆写 ACCEPT.txt,对不上就抛 NodeError。
|
||||||
@dataclass
|
@dataclass
|
||||||
class AcceptanceReport:
|
class AcceptanceReport:
|
||||||
"""DOM / page-state checklist. One instance per flow run."""
|
|
||||||
|
|
||||||
flow: str = "default"
|
flow: str = "default"
|
||||||
lines: list[AcceptLine] = field(default_factory=list)
|
lines: list[AcceptLine] = field(default_factory=list)
|
||||||
|
|
||||||
|
# 清空已记的步骤,下一次页面流重新计。
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
self.lines = []
|
self.lines = []
|
||||||
|
|
||||||
|
# 核对一组字段。对不上就记 FAIL、失败时截图,并抛 NodeError 停下这一步。
|
||||||
def check(
|
def check(
|
||||||
self,
|
self,
|
||||||
step: str,
|
step: str,
|
||||||
|
|
@ -122,6 +130,7 @@ class AcceptanceReport:
|
||||||
evidence_shot(browser, screenshot_on_fail, failed=True)
|
evidence_shot(browser, screenshot_on_fail, failed=True)
|
||||||
raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches))
|
raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches))
|
||||||
|
|
||||||
|
# 记一步事实,不和期望逐字段比。ok 为假且 raise_error 时抛 NodeError。
|
||||||
def note(
|
def note(
|
||||||
self,
|
self,
|
||||||
step: str,
|
step: str,
|
||||||
|
|
@ -138,6 +147,7 @@ class AcceptanceReport:
|
||||||
if not ok and raise_error:
|
if not ok and raise_error:
|
||||||
raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items()))
|
raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items()))
|
||||||
|
|
||||||
|
# 把当前汇总写进 ACCEPT.txt。写失败只打印,不把页面流打成另一次错误。
|
||||||
def dump(self) -> None:
|
def dump(self) -> None:
|
||||||
path = accept_log_path(self.flow)
|
path = accept_log_path(self.flow)
|
||||||
try:
|
try:
|
||||||
|
|
@ -146,6 +156,7 @@ class AcceptanceReport:
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"ACCEPT log write skipped: {exc}", flush=True)
|
print(f"ACCEPT log write skipped: {exc}", flush=True)
|
||||||
|
|
||||||
|
# 汇总文本:第一行是通过数,后面是每步的 ACCEPT 行。
|
||||||
def summary_text(self) -> str:
|
def summary_text(self) -> str:
|
||||||
passed = sum(1 for line in self.lines if line.ok)
|
passed = sum(1 for line in self.lines if line.ok)
|
||||||
rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"]
|
rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"]
|
||||||
|
|
|
||||||
|
|
@ -1,627 +1,33 @@
|
||||||
# coding=utf-8
|
# 合同班浏览器适配。复用 rpa_browser 共享适配器。
|
||||||
"""
|
#
|
||||||
Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。
|
# 本文件只做导入入口,步骤函数仍 from apa_browser_adapter import ApaBrowserAdapter。
|
||||||
"""
|
# Chrome 用户目录和调试口用共享默认:CONTRACT_CREATE 口 9333。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import sys
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
_FLOW_DEBUG_PORTS = {
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
"CONTRACT_CREATE": 9333,
|
|
||||||
"REBATE_FIND": 9335,
|
from rpa_browser.adapter import ( # noqa: E402
|
||||||
"default": 9334,
|
ApaBrowserAdapter,
|
||||||
}
|
REPO_ROOT,
|
||||||
|
chrome_debug_port,
|
||||||
|
resolve_chrome_profile_base,
|
||||||
def _is_inside_repo(path: Path, repo_root: Path) -> bool:
|
resolve_chrome_profile_dir,
|
||||||
try:
|
wait_debug_port,
|
||||||
path.resolve().relative_to(repo_root.resolve())
|
wait_devtools_port,
|
||||||
except ValueError:
|
)
|
||||||
return False
|
|
||||||
return True
|
__all__ = [
|
||||||
|
"ApaBrowserAdapter",
|
||||||
|
"REPO_ROOT",
|
||||||
def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path:
|
"chrome_debug_port",
|
||||||
"""Chrome user-data dir must stay outside the repo so list_dir never walks 3GB."""
|
"resolve_chrome_profile_base",
|
||||||
root = (repo_root or REPO_ROOT).resolve()
|
"resolve_chrome_profile_dir",
|
||||||
outside = (root.parent / "rpa-chrome-profile").resolve()
|
"wait_debug_port",
|
||||||
raw = os.getenv("RPA_CHROME_PROFILE") if requested is None else requested
|
"wait_devtools_port",
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_chrome_profile_dir(
|
|
||||||
flow: str = "default",
|
|
||||||
requested: str | None = None,
|
|
||||||
*,
|
|
||||||
repo_root: Path | None = None,
|
|
||||||
) -> Path:
|
|
||||||
"""Stable profile per flow so later tasks reuse login cookies."""
|
|
||||||
base = resolve_chrome_profile_base(requested, repo_root=repo_root)
|
|
||||||
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (flow or "default").strip())
|
|
||||||
return (base / (safe or "default")).resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def chrome_debug_port(flow: str = "default") -> int:
|
|
||||||
raw = (os.getenv("RPA_CHROME_DEBUG_PORT") or "").strip()
|
|
||||||
if raw:
|
|
||||||
try:
|
|
||||||
return int(raw)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return _FLOW_DEBUG_PORTS.get((flow or "default").strip(), 9333)
|
|
||||||
|
|
||||||
|
|
||||||
def _port_open(port: int) -> bool:
|
|
||||||
try:
|
|
||||||
with socket.create_connection(("127.0.0.1", port), timeout=0.4):
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _read_devtools_port(profile_dir: Path) -> int | None:
|
|
||||||
marker = profile_dir / "DevToolsActivePort"
|
|
||||||
if not marker.is_file():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
first = marker.read_text(encoding="utf-8").splitlines()[0].strip()
|
|
||||||
port = int(first)
|
|
||||||
except (OSError, ValueError, IndexError):
|
|
||||||
return None
|
|
||||||
return port if port > 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def _chrome_service():
|
|
||||||
from selenium.webdriver.chrome.service import Service
|
|
||||||
|
|
||||||
chromedriver = os.getenv("CHROMEDRIVER_PATH")
|
|
||||||
if chromedriver and os.path.exists(chromedriver):
|
|
||||||
return Service(chromedriver)
|
|
||||||
from webdriver_manager.chrome import ChromeDriverManager
|
|
||||||
|
|
||||||
return Service(ChromeDriverManager().install())
|
|
||||||
|
|
||||||
|
|
||||||
def _attach_chrome(service, port: int):
|
|
||||||
from selenium import webdriver
|
|
||||||
from selenium.webdriver.chrome.options import Options
|
|
||||||
|
|
||||||
options = Options()
|
|
||||||
options.add_experimental_option("debuggerAddress", f"127.0.0.1:{port}")
|
|
||||||
driver = webdriver.Chrome(service=service, options=options)
|
|
||||||
driver.execute_script("return 1")
|
|
||||||
return driver
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_chrome(service, profile_dir: Path, port: int):
|
|
||||||
from selenium import webdriver
|
|
||||||
from selenium.webdriver.chrome.options import Options
|
|
||||||
|
|
||||||
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")
|
|
||||||
options.add_argument("--remote-allow-origins=*")
|
|
||||||
options.add_argument(f"--remote-debugging-port={port}")
|
|
||||||
options.add_argument("--user-data-dir=" + str(profile_dir))
|
|
||||||
return webdriver.Chrome(service=service, options=options)
|
|
||||||
|
|
||||||
|
|
||||||
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;没有就按稳定 profile 新开。"""
|
|
||||||
if self._driver is not None:
|
|
||||||
return self._driver
|
|
||||||
|
|
||||||
profile_dir = resolve_chrome_profile_dir(self._flow)
|
|
||||||
if _is_inside_repo(profile_dir, REPO_ROOT):
|
|
||||||
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_dir}")
|
|
||||||
os.makedirs(profile_dir, exist_ok=True)
|
|
||||||
port = _read_devtools_port(profile_dir) or chrome_debug_port(self._flow)
|
|
||||||
|
|
||||||
driver = None
|
|
||||||
if _port_open(port):
|
|
||||||
try:
|
|
||||||
driver = _attach_chrome(_chrome_service(), port)
|
|
||||||
print(f"Chrome 复用已有实例 flow={self._flow} port={port}", flush=True)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"Chrome 复用失败 port={port}:{exc}", flush=True)
|
|
||||||
driver = None
|
|
||||||
if driver is None:
|
|
||||||
launch_port = chrome_debug_port(self._flow)
|
|
||||||
try:
|
|
||||||
driver = _launch_chrome(_chrome_service(), profile_dir, launch_port)
|
|
||||||
print(
|
|
||||||
f"Chrome 新开实例 flow={self._flow} port={launch_port} profile={profile_dir}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
retry_port = _read_devtools_port(profile_dir)
|
|
||||||
if retry_port and _port_open(retry_port):
|
|
||||||
driver = _attach_chrome(_chrome_service(), retry_port)
|
|
||||||
print(f"Chrome 锁定后改为复用 port={retry_port}", flush=True)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"打开 Chrome 失败:{exc}") from exc
|
|
||||||
|
|
||||||
driver.set_page_load_timeout(45)
|
|
||||||
driver.set_script_timeout(30)
|
|
||||||
self._driver = driver
|
|
||||||
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)
|
|
||||||
# 原生 <select>
|
|
||||||
try:
|
|
||||||
Select(el).select_by_visible_text(value)
|
|
||||||
time.sleep(0.3)
|
|
||||||
if self._selected_text_matches(el, value):
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# 兜底:JS 定位 option 并设置选中
|
|
||||||
self._select_via_js(el, value)
|
|
||||||
time.sleep(0.3)
|
|
||||||
if self._selected_text_matches(el, value):
|
|
||||||
return
|
|
||||||
raise RuntimeError(f"select_first 未选中目标项: {value}")
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
continue
|
|
||||||
raise RuntimeError(f"select_first failed: {selectors}, last: {last_error}")
|
|
||||||
|
|
||||||
def _selected_text_matches(self, el, value: str) -> bool:
|
|
||||||
try:
|
|
||||||
script = (
|
|
||||||
"var el = arguments[0];"
|
|
||||||
"if (el.selectedIndex != null) {"
|
|
||||||
" var o = el.options[el.selectedIndex];"
|
|
||||||
" return o ? (o.textContent || o.text || '').trim() : '';"
|
|
||||||
"}"
|
|
||||||
"return (el.value || '').trim();"
|
|
||||||
)
|
|
||||||
selected = self._driver.execute_script(script, el)
|
|
||||||
return (selected or "").strip() == str(value).strip()
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _select_via_js(self, el, value: str) -> None:
|
|
||||||
self._driver.execute_script(
|
|
||||||
"""
|
|
||||||
var el = arguments[0];
|
|
||||||
var value = arguments[1];
|
|
||||||
var options = el.options || [];
|
|
||||||
for (var i = 0; i < options.length; i++) {
|
|
||||||
var t = (options[i].textContent || options[i].text || '').trim();
|
|
||||||
if (t === value || options[i].value === value) {
|
|
||||||
el.selectedIndex = i;
|
|
||||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
el,
|
|
||||||
str(value),
|
|
||||||
)
|
|
||||||
|
|
||||||
def upload_file_first(self, selectors: list, file_path: str) -> None:
|
|
||||||
self._ensure_open()
|
|
||||||
last_error = None
|
|
||||||
for sel in selectors:
|
|
||||||
try:
|
|
||||||
el = self._find(sel)
|
|
||||||
el.send_keys(file_path)
|
|
||||||
time.sleep(1)
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
continue
|
|
||||||
raise RuntimeError(f"upload_file_first failed: {selectors}, last: {last_error}")
|
|
||||||
|
|
||||||
def read_first(self, selectors: list, value_name: str = "") -> str:
|
|
||||||
self._ensure_open()
|
|
||||||
last_error = None
|
|
||||||
for sel in selectors:
|
|
||||||
try:
|
|
||||||
el = self._find(sel)
|
|
||||||
result = el.text or el.get_attribute("value") or ""
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
continue
|
|
||||||
raise RuntimeError(f"read_first failed: {selectors}, last: {last_error}")
|
|
||||||
|
|
||||||
def read_body_text(self) -> str:
|
|
||||||
self._ensure_open()
|
|
||||||
try:
|
|
||||||
return self._driver.find_element("tag name", "body").text
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def screenshot(self, name: str) -> str:
|
|
||||||
from accept import artifact_dir
|
|
||||||
|
|
||||||
save_dir = artifact_dir(self._flow)
|
|
||||||
save_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = save_dir / f"{name}.png"
|
|
||||||
if self._driver:
|
|
||||||
self._driver.save_screenshot(str(path))
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
def accept_alert(self) -> str:
|
|
||||||
"""Accept a native JS alert/confirm and return its text. Empty if none."""
|
|
||||||
self._ensure_open()
|
|
||||||
try:
|
|
||||||
alert = self._driver.switch_to.alert
|
|
||||||
text = (alert.text or "").strip()
|
|
||||||
alert.accept()
|
|
||||||
return text
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def reopen_url(self, url: str) -> None:
|
|
||||||
"""Close the current tab and open url in a fresh tab of the same Chrome."""
|
|
||||||
driver = self._get_driver()
|
|
||||||
old = None
|
|
||||||
try:
|
|
||||||
old = driver.current_window_handle
|
|
||||||
driver.switch_to.new_window("tab")
|
|
||||||
new = driver.current_window_handle
|
|
||||||
if old and old != new:
|
|
||||||
try:
|
|
||||||
driver.switch_to.window(old)
|
|
||||||
driver.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
driver.switch_to.window(new)
|
|
||||||
except Exception:
|
|
||||||
handles = driver.window_handles
|
|
||||||
if handles:
|
|
||||||
driver.switch_to.window(handles[-1])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.open_url(url)
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,606 @@
|
||||||
|
# 合同页面共用:类型、选择器、工具栏、fdId、验收报告。
|
||||||
|
#
|
||||||
|
# 各 S0x 步骤从这里取 BrowserAdapter / ContractFlowContext / _ACCEPT。
|
||||||
|
# 改字段选择器或工具栏点击,步骤文件一起变。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from accept import AcceptanceReport
|
||||||
|
|
||||||
|
|
||||||
|
# 合同页面操作口,由 ApaBrowserAdapter 实现。
|
||||||
|
class BrowserAdapter(Protocol):
|
||||||
|
# 打开指定 URL。
|
||||||
|
def open_url(self, url: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 按选择器列表填第一个匹配到的输入框。
|
||||||
|
def fill_first(self, selectors: list[str], value: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 点第一个匹配到的元素。
|
||||||
|
def click_first(self, selectors: list[str]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 用原生事件点第一个匹配到的元素。
|
||||||
|
def click_native(self, selectors: list[str]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 双击第一个匹配到的元素。
|
||||||
|
def double_click_first(self, selectors: list[str]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 等到页面正文出现指定文本。
|
||||||
|
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 等到选择器列表里有一个元素出现。
|
||||||
|
def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 选择器列表里是否已有匹配元素。
|
||||||
|
def element_exists(self, selectors: list[str]) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 切入第一个匹配到的 iframe。
|
||||||
|
def switch_to_frame(self, selectors: list[str]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 切回主文档。
|
||||||
|
def switch_to_default_content(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 在第一个匹配到的 select 上选中指定值。
|
||||||
|
def select_first(self, selectors: list[str], value: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 按 xform property 名选中 fancy-select 选项。
|
||||||
|
def select_xform(self, name: str, value: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 在当前页跑一段脚本并返回结果。
|
||||||
|
def execute_script(self, script: str, *args):
|
||||||
|
...
|
||||||
|
|
||||||
|
# 把本地文件塞进第一个匹配到的 file input。
|
||||||
|
def upload_file_first(self, selectors: list[str], file_path: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 读第一个匹配元素的值或文本。
|
||||||
|
def read_first(self, selectors: list[str], value_name: str = "") -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 读当前页可见正文。
|
||||||
|
def read_body_text(self) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 按名字截一张图,返回保存路径。
|
||||||
|
def screenshot(self, name: str) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 有原生 alert 就点确定并返回文案。
|
||||||
|
def accept_alert(self) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 关掉当前页再打开指定 URL。
|
||||||
|
def reopen_url(self, url: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# 合同货品明细一行,供选择商品弹窗和明细表回填。
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContractGoodsLine:
|
||||||
|
operation_code: str
|
||||||
|
goods_code: str
|
||||||
|
goods_name: str
|
||||||
|
approval_no: str
|
||||||
|
spec: str
|
||||||
|
unit: str
|
||||||
|
quantity: float
|
||||||
|
mdm_code: str = ""
|
||||||
|
unit_price: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# 一次合同起草页的入参。账密、客商、货品、提交方式都在这里。
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContractFlowContext:
|
||||||
|
contract_url: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
supplier_name: str
|
||||||
|
order_date: str
|
||||||
|
source_type: str
|
||||||
|
template_type: str = "FORMAT"
|
||||||
|
template_name: str = ""
|
||||||
|
customer_code: str = ""
|
||||||
|
customer_name: str = ""
|
||||||
|
contract_category: str = ""
|
||||||
|
settlement_type: str = "付款"
|
||||||
|
contract_background: str = ""
|
||||||
|
contract_nature: str = "主合同"
|
||||||
|
amount_currency: str = "人民币"
|
||||||
|
amount_type: str = "合同标的金额"
|
||||||
|
amount: str = ""
|
||||||
|
contract_title: str = ""
|
||||||
|
contract_end_date: str = ""
|
||||||
|
contract_sign_date: str = ""
|
||||||
|
contract_start_date: str = ""
|
||||||
|
is_major_contract: str = "否"
|
||||||
|
is_prepayment: str = "否"
|
||||||
|
belong_year: str = ""
|
||||||
|
payment_method: str = "账期支付"
|
||||||
|
payment_days: str = "30"
|
||||||
|
contract_status: str = "正式合同"
|
||||||
|
contains_blood_products: str = "否"
|
||||||
|
submit_mode: str = "submit"
|
||||||
|
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
||||||
|
merged_pdf_path: str | None = None
|
||||||
|
contract_document_path: str = ""
|
||||||
|
order_no_list: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
_ACCEPT = AcceptanceReport(flow="CONTRACT_CREATE")
|
||||||
|
|
||||||
|
|
||||||
|
# 拼合同标题:有 contract_title 用它,否则客商名+类别+订单日。
|
||||||
|
def _expected_title(ctx: ContractFlowContext) -> str:
|
||||||
|
title = (ctx.contract_title or "").strip()
|
||||||
|
if title:
|
||||||
|
return title
|
||||||
|
who = (ctx.customer_name or ctx.supplier_name or "").strip()
|
||||||
|
cate = (ctx.contract_category or "采购协议").strip()
|
||||||
|
date = (ctx.order_date or "").replace("-", "")
|
||||||
|
return f"{who}{cate}{date}"
|
||||||
|
|
||||||
|
|
||||||
|
# 合同起草页(模板选择)直达 URL,绕过 SPA 菜单/iframe 复杂性
|
||||||
|
DRAFT_PAGE_URL = (
|
||||||
|
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/draft/"
|
||||||
|
"draft.jsp?isSimpleCategory=false"
|
||||||
|
"&mainModelName=com.landray.kmss.km.agreement.model.KmAgreementApply"
|
||||||
|
"&modelName=com.landray.kmss.km.agreement.model.KmAgreementTmpl"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 非格式-采购协议(2025集团版)模板 ID(测试环境固定值)
|
||||||
|
# 直接跳转表单页,绕过「点模板」这一依赖前端委托事件、Selenium 点击无效的步骤
|
||||||
|
TEMPLATE_ID = "198d10b04325279b8ffeb8b4c6493b5a"
|
||||||
|
FORM_PAGE_URL = (
|
||||||
|
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/"
|
||||||
|
"kmAgreementApply.do?method=add"
|
||||||
|
f"&fdTemplateId={TEMPLATE_ID}"
|
||||||
|
f"&.fdTemplate={TEMPLATE_ID}"
|
||||||
|
f"&i.docTemplate={TEMPLATE_ID}"
|
||||||
|
"&s_css=default"
|
||||||
|
)
|
||||||
|
|
||||||
|
VIEW_PAGE_URL = (
|
||||||
|
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/"
|
||||||
|
"kmAgreementApply.do?method=view&fdId={fd_id}"
|
||||||
|
)
|
||||||
|
EDIT_PAGE_URL = (
|
||||||
|
"http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/"
|
||||||
|
"kmAgreementApply.do?method=edit&fdId={fd_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Landray/LUI 表单字段的真实 name
|
||||||
|
F = {
|
||||||
|
"customer_code": 'input[name="extendDataFormInfo.value(fd_customeropcode)"]',
|
||||||
|
"customer_name": 'input[name="extendDataFormInfo.value(fd_customername)"]',
|
||||||
|
"contract_category": 'input[name="extendDataFormInfo.value(fd_contract_cate_type)"]',
|
||||||
|
"settlement_type": 'select#fdSettype',
|
||||||
|
"contract_background": 'textarea[name="extendDataFormInfo.value(fd_desc_info)"]',
|
||||||
|
"nature_main": 'input[name="fdType"][value="00"]',
|
||||||
|
"amount": 'input[name="fdAccount"]',
|
||||||
|
"sign_date": 'input[name="fdSigndate"]',
|
||||||
|
"begin_date": 'input[name="fdEffedate"]',
|
||||||
|
"end_date": 'input[name="fdEnddate"]',
|
||||||
|
"is_major_contract": 'input[name="extendDataFormInfo.value(fd_is_major_contract)"]',
|
||||||
|
"is_prepayment": 'input[name="extendDataFormInfo.value(fd_advance_amout)"]',
|
||||||
|
"belong_year": 'input[name="extendDataFormInfo.value(fd_contract_year_text)"]',
|
||||||
|
"payment_method": 'input[name="extendDataFormInfo.value(fd_paytype)"]',
|
||||||
|
"payment_days": 'input[name="extendDataFormInfo.value(fd_pay_time_limit)"]',
|
||||||
|
"contract_status": 'input[name="extendDataFormInfo.value(fd_suconstatus)"]',
|
||||||
|
"contains_blood": 'input[name="extendDataFormInfo.value(fd_3d1ce3953f1162)"]',
|
||||||
|
"doc_subject": 'input[name="docSubject"]',
|
||||||
|
}
|
||||||
|
|
||||||
|
# xform fancy-select 的 property 名(select_xform 用)
|
||||||
|
X = {
|
||||||
|
"contract_category": "extendDataFormInfo.value(fd_contract_cate_type)",
|
||||||
|
"cover_template": "extendDataFormInfo.value(fd_contract_cover_type)",
|
||||||
|
"amount_type": "extendDataFormInfo.value(fd_all_amount_type)",
|
||||||
|
"is_major_contract": "extendDataFormInfo.value(fd_is_major_contract)",
|
||||||
|
"is_prepayment": "extendDataFormInfo.value(fd_advance_amout)",
|
||||||
|
"payment_method": "extendDataFormInfo.value(fd_paytype)",
|
||||||
|
"contract_status": "extendDataFormInfo.value(fd_suconstatus)",
|
||||||
|
"contains_blood": "extendDataFormInfo.value(fd_3d1ce3953f1162)",
|
||||||
|
}
|
||||||
|
|
||||||
|
_YES_NO = {
|
||||||
|
"1": "是",
|
||||||
|
"2": "否",
|
||||||
|
"Y": "是",
|
||||||
|
"N": "否",
|
||||||
|
"YES": "是",
|
||||||
|
"NO": "否",
|
||||||
|
"TRUE": "是",
|
||||||
|
"FALSE": "否",
|
||||||
|
}
|
||||||
|
_CATEGORY_ALIASES = {
|
||||||
|
"采购协议": "其他协议(采购)",
|
||||||
|
"采购": "其他协议(采购)",
|
||||||
|
}
|
||||||
|
_PAYMENT_ALIASES = {
|
||||||
|
"账期支付": "电汇",
|
||||||
|
"账期": "电汇",
|
||||||
|
"承兑": "银行承兑汇票",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 把 1/2、Y/N 收成页面上的「是」「否」。
|
||||||
|
def _yes_no(value: str) -> str:
|
||||||
|
raw = (value or "").strip()
|
||||||
|
return _YES_NO.get(raw.upper(), raw)
|
||||||
|
|
||||||
|
|
||||||
|
# 把合同类别别名收成页面下拉的实际选项。
|
||||||
|
def _category_option(value: str) -> str:
|
||||||
|
raw = (value or "").strip()
|
||||||
|
return _CATEGORY_ALIASES.get(raw, raw)
|
||||||
|
|
||||||
|
|
||||||
|
# 把付款方式别名收成页面下拉的实际选项。「账期支付」对应「电汇」。
|
||||||
|
def _payment_option(value: str) -> str:
|
||||||
|
raw = (value or "").strip()
|
||||||
|
return _PAYMENT_ALIASES.get(raw, raw)
|
||||||
|
|
||||||
|
# 货品明细行的字段后缀;行索引从 0 开始(fd_detail1.0.* 为第 1 行)
|
||||||
|
GOODS_FIELD_SUFFIXES = {
|
||||||
|
"goods_code": "fd_opcode",
|
||||||
|
"goods_name": "fd_goodsname",
|
||||||
|
"goods_mdm": "fd_mdmCode",
|
||||||
|
"goods_approval": "fd_registdocno",
|
||||||
|
"goods_spec": "fd_goodstype",
|
||||||
|
"goods_unit": "fd_goodsunit",
|
||||||
|
"goods_qty": "fd_goodsqty",
|
||||||
|
"goods_price": "fd_unitprice",
|
||||||
|
}
|
||||||
|
|
||||||
|
ADD_ROW_SELECTORS = [
|
||||||
|
"text=添加行",
|
||||||
|
"text=新增明细",
|
||||||
|
"text=新增行",
|
||||||
|
"button:contains(添加行)",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 货品选择窗口:点「选择商品」弹出 EopBasedataMaterial 列表,按操作码/名称搜索回填。
|
||||||
|
# lui-id-* 每次加载会变,不要写死;用 title / 按钮文本定位。
|
||||||
|
GOODS_PICKER = {
|
||||||
|
"trigger": [
|
||||||
|
'div[title="选择商品"]',
|
||||||
|
'div.lui-component.lui_widget_btn_txt',
|
||||||
|
"text=选择商品",
|
||||||
|
],
|
||||||
|
"frame": [
|
||||||
|
'iframe[src*="EopBasedataMaterial"]',
|
||||||
|
'iframe[src*="dialog_fdMaterial"]',
|
||||||
|
'iframe[src*="common-template"]',
|
||||||
|
],
|
||||||
|
"search_input": ["input.search-box-text", "input[placeholder*='关键字']"],
|
||||||
|
"search_button": [
|
||||||
|
"input.search-box-btn",
|
||||||
|
"a.search-box-btn",
|
||||||
|
"div.search-box-btn",
|
||||||
|
"span.search-box-btn",
|
||||||
|
"input[value='搜索']",
|
||||||
|
"input[value='查询']",
|
||||||
|
"[class*='search-box'] [class*='btn']",
|
||||||
|
],
|
||||||
|
"result_checkbox": ['input[name="List_Selected"]'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 客商选择窗口(点击客户名称旁「选择」按钮弹出)。若真实页面改版,仅需调整此处。
|
||||||
|
CUSTOMER_PICKER = {
|
||||||
|
"trigger": [
|
||||||
|
'div.inputselectsgl[onclick*="fdCustomerEdit"]',
|
||||||
|
'div[class*="inputselect"][onclick*="fdCustomer"]',
|
||||||
|
],
|
||||||
|
"frame": [
|
||||||
|
'iframe[src*="common-template"]',
|
||||||
|
'iframe[src*="KmRelativeMain"]',
|
||||||
|
'iframe[src*="relative"]',
|
||||||
|
],
|
||||||
|
"search_input": ["input.search-box-text"],
|
||||||
|
"search_button": ["input.search-box-btn"],
|
||||||
|
"result_checkbox": ['input[name="List_Selected"]'],
|
||||||
|
"confirm_button": ["div.btn.ok", "div.lui_toolbar_btn_def"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_PORTAL_MARKERS = ("切换门户", "待办", "待阅", "合同管理", "合同起草")
|
||||||
|
|
||||||
|
|
||||||
|
# 看页面是否已进门户。看到「切换门户」或待办/待阅即视为已登录。
|
||||||
|
def _is_logged_in(browser: BrowserAdapter) -> bool:
|
||||||
|
body = browser.read_body_text() or ""
|
||||||
|
return any(marker in body for marker in _PORTAL_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
# 取签订日。接口 3 给了用它,否则回退 order_date。
|
||||||
|
def sign_date_for(ctx: ContractFlowContext) -> str:
|
||||||
|
return (ctx.contract_sign_date or ctx.order_date or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# 取开始日。接口 3 给了用它,否则回退 order_date。
|
||||||
|
def begin_date_for(ctx: ContractFlowContext) -> str:
|
||||||
|
return (ctx.contract_start_date or ctx.order_date or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# 关掉挡在表单前面的「关闭/取消」弹层。
|
||||||
|
def _close_blocking_dialogs(browser: BrowserAdapter) -> None:
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const labels = ['关闭', '取消'];
|
||||||
|
function clickIn(win) {
|
||||||
|
const nodes = Array.from(win.document.querySelectorAll('div, a, button, span'));
|
||||||
|
for (const label of labels) {
|
||||||
|
const hit = nodes.find((el) => (el.textContent || '').trim() === label && el.children.length === 0);
|
||||||
|
if (hit) { hit.click(); return label; }
|
||||||
|
}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try { const hit = clickIn(win.frames[i]); if (hit) return hit; } catch (e) {}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return clickIn(window);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
|
||||||
|
# 读当前页 URL,跨 iframe 时尽量取 top.location。
|
||||||
|
def _current_url(browser: BrowserAdapter) -> str:
|
||||||
|
try:
|
||||||
|
return str(
|
||||||
|
browser.execute_script(
|
||||||
|
"try { return (window.top.location && window.top.location.href) || location.href; }"
|
||||||
|
" catch (e) { return location.href; }"
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 从 URL、隐藏域或 Com_Parameter 读本单 fdId。
|
||||||
|
def _read_document_id(browser: BrowserAdapter) -> str:
|
||||||
|
try:
|
||||||
|
browser.switch_to_default_content()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
value = browser.execute_script(
|
||||||
|
"""
|
||||||
|
function fromHref(href) {
|
||||||
|
const fromUrl = String(href || '').match(/[?&]fdId=([^&]+)/i);
|
||||||
|
if (fromUrl && fromUrl[1]) return decodeURIComponent(fromUrl[1]);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function fromDoc(win) {
|
||||||
|
try {
|
||||||
|
const id = fromHref(win.location && win.location.href);
|
||||||
|
if (id) return id;
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
const input = win.document.querySelector('input[name="fdId"], input[name="fdid"]');
|
||||||
|
if (input && String(input.value || '').trim()) return String(input.value).trim();
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
if (win.Com_Parameter && win.Com_Parameter.fdId) return String(win.Com_Parameter.fdId);
|
||||||
|
} catch (e) {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function walk(win) {
|
||||||
|
const id = fromDoc(win);
|
||||||
|
if (id) return id;
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try {
|
||||||
|
const inner = walk(win.frames[i]);
|
||||||
|
if (inner) return inner;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return walk(window);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# 用 fdId 打开合同详情页。没有 fdId 则什么都不做。
|
||||||
|
def _open_submitted_view(browser: BrowserAdapter, fd_id: str) -> None:
|
||||||
|
doc_id = (fd_id or "").strip() or _read_document_id(browser)
|
||||||
|
if not doc_id:
|
||||||
|
print("成功页没有 fdId,无法自动打开详情", flush=True)
|
||||||
|
return
|
||||||
|
url = VIEW_PAGE_URL.format(fd_id=doc_id)
|
||||||
|
print("打开合同详情:", url, flush=True)
|
||||||
|
browser.open_url(url)
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
|
||||||
|
# 拼 LUI 工具栏按钮的精确文本 xpath,避免 contains 点到别的按钮。
|
||||||
|
def _lui_btn_xpath(label: str) -> str:
|
||||||
|
safe = (label or "").replace("'", "")
|
||||||
|
return (
|
||||||
|
"//*[contains(@class,'lui_widget_btn_txt') and normalize-space()='%s']" % safe
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 点 LUI 工具栏上指定文案的按钮。脚本点不到再走 native/first。
|
||||||
|
def _click_toolbar_button(browser: BrowserAdapter, label: str) -> bool:
|
||||||
|
try:
|
||||||
|
browser.switch_to_default_content()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
clicked = browser.execute_script(_LUI_TOOLBAR_CLICK_JS, label)
|
||||||
|
print(f"toolbar {label} js={clicked}", flush=True)
|
||||||
|
if isinstance(clicked, dict) and clicked.get("ok"):
|
||||||
|
return True
|
||||||
|
selectors = [_lui_btn_xpath(label)]
|
||||||
|
try:
|
||||||
|
browser.click_native(selectors)
|
||||||
|
print(f"toolbar {label} native=ok", flush=True)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"toolbar {label} native={exc}", flush=True)
|
||||||
|
try:
|
||||||
|
browser.click_first(selectors)
|
||||||
|
print(f"toolbar {label} first=ok", flush=True)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"toolbar {label} first={exc}", flush=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_LUI_TOOLBAR_FIND_JS = """
|
||||||
|
const label = String(arguments[0] || '');
|
||||||
|
function visible(el) {
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
return box.width > 2 && box.height > 2;
|
||||||
|
}
|
||||||
|
function textOf(el) {
|
||||||
|
return (el.textContent || '').replace(/\\s+/g, '').trim();
|
||||||
|
}
|
||||||
|
function findIn(win) {
|
||||||
|
const nodes = Array.from(win.document.querySelectorAll(
|
||||||
|
'div.lui-component.lui_widget_btn_txt, .lui_widget_btn_txt'
|
||||||
|
));
|
||||||
|
const hit = nodes.find((el) => textOf(el) === label && visible(el));
|
||||||
|
if (hit) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
cid: hit.getAttribute('data-lui-cid') || '',
|
||||||
|
id: hit.id || '',
|
||||||
|
count: nodes.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try {
|
||||||
|
const inner = findIn(win.frames[i]);
|
||||||
|
if (inner && inner.ok) return inner;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
texts: nodes.map((el) => textOf(el)).filter(Boolean).slice(0, 12)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return findIn(window);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_LUI_TOOLBAR_CLICK_JS = """
|
||||||
|
const label = String(arguments[0] || '');
|
||||||
|
function visible(el) {
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
return box.width > 2 && box.height > 2;
|
||||||
|
}
|
||||||
|
function textOf(el) {
|
||||||
|
return (el.textContent || '').replace(/\\s+/g, '').trim();
|
||||||
|
}
|
||||||
|
function fire(el, win) {
|
||||||
|
const target = el.closest('[data-lui-cid]') || el;
|
||||||
|
try { target.scrollIntoView({block: 'center', inline: 'nearest'}); } catch (e) {}
|
||||||
|
['mouseover', 'mousedown', 'mouseup', 'click'].forEach((type) => {
|
||||||
|
target.dispatchEvent(new MouseEvent(type, {
|
||||||
|
bubbles: true, cancelable: true, view: win, buttons: 1
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
target.click();
|
||||||
|
}
|
||||||
|
function clickIn(win) {
|
||||||
|
const nodes = Array.from(win.document.querySelectorAll(
|
||||||
|
'div.lui-component.lui_widget_btn_txt, .lui_widget_btn_txt'
|
||||||
|
));
|
||||||
|
const hit = nodes.find((el) => textOf(el) === label && visible(el));
|
||||||
|
if (hit) {
|
||||||
|
fire(hit, win);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
via: 'btn_txt',
|
||||||
|
cid: hit.getAttribute('data-lui-cid') || '',
|
||||||
|
id: hit.id || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try {
|
||||||
|
const inner = clickIn(win.frames[i]);
|
||||||
|
if (inner && inner.ok) return inner;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
label: label,
|
||||||
|
texts: nodes.map((el) => textOf(el)).filter(Boolean).slice(0, 12)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return clickIn(window);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# 工具栏上是否已经出现指定文案的可见按钮。
|
||||||
|
def _lui_toolbar_button_exists(browser: BrowserAdapter, label: str) -> bool:
|
||||||
|
found = browser.execute_script(_LUI_TOOLBAR_FIND_JS, label)
|
||||||
|
ok = isinstance(found, dict) and bool(found.get("ok"))
|
||||||
|
print(f"toolbar {label} exists={found}", flush=True)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# 等到工具栏出现指定按钮。超时返回 False。
|
||||||
|
def _wait_lui_toolbar_button(
|
||||||
|
browser: BrowserAdapter, label: str, timeout_seconds: float = 10
|
||||||
|
) -> bool:
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
while time.time() < deadline:
|
||||||
|
if _lui_toolbar_button_exists(browser, label):
|
||||||
|
return True
|
||||||
|
time.sleep(0.4)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 点工具栏「提交」。
|
||||||
|
def _click_top_submit(browser: BrowserAdapter) -> bool:
|
||||||
|
return _click_toolbar_button(browser, "提交")
|
||||||
|
|
||||||
|
|
||||||
|
# 判断字符串是不是正式合同号。占位符、表头、手机号不算。
|
||||||
|
def _usable_contract_no(value: str) -> bool:
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text or text in ("提交时自动生成", "自动生成", "==请选择==", "[]"):
|
||||||
|
return False
|
||||||
|
if text in ("合同标题", "合同编号", "客户编码", "客户名称", "申请时间", "合同(协议)全称"):
|
||||||
|
return False
|
||||||
|
if text.startswith("HT"):
|
||||||
|
return True
|
||||||
|
if text.isdigit() and len(text) >= 10:
|
||||||
|
return True
|
||||||
|
return any(ch.isdigit() for ch in text) and len(text) >= 8 and " " not in text
|
||||||
|
|
||||||
|
|
||||||
|
# 读第一个匹配选择器的值。元素不存在或读失败返回空字符串。
|
||||||
|
def _try_read(browser: BrowserAdapter, selectors: list[str]) -> str:
|
||||||
|
if not browser.element_exists(selectors):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return (browser.read_first(selectors, "") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 合同版本类型的验收文案:非格式合同或格式合同。
|
||||||
|
def contract_version_label(ctx: ContractFlowContext) -> str:
|
||||||
|
return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同"
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
"""CONTRACT_CREATE: 入参组装 + Selenium 起草。结果返回合同号,不回调旧状态中心。"""
|
# CONTRACT_CREATE: 入参组装 + Selenium 起草。
|
||||||
|
#
|
||||||
|
# 结果返回合同号,不回调旧状态中心。
|
||||||
|
# 合同系统账密只认 CONTRACT_USERNAME / CONTRACT_PASSWORD 或入参,缺则失败。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -12,7 +16,6 @@ from apa_browser_adapter import ApaBrowserAdapter
|
||||||
from contract_draft import (
|
from contract_draft import (
|
||||||
ContractDraft,
|
ContractDraft,
|
||||||
contract_draft_from_dict,
|
contract_draft_from_dict,
|
||||||
is_placeholder_customer_code,
|
|
||||||
load_contract_draft,
|
load_contract_draft,
|
||||||
)
|
)
|
||||||
from contract_flow_steps import ContractFlowContext, run_contract_create_flow
|
from contract_flow_steps import ContractFlowContext, run_contract_create_flow
|
||||||
|
|
@ -22,16 +25,19 @@ from pdf_utils import ensure_merged_pdf
|
||||||
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
|
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
|
||||||
|
|
||||||
|
|
||||||
|
# 入参缺失 / 页面元素找不到 / 页面改版。
|
||||||
class ContractCreateError(NodeError):
|
class ContractCreateError(NodeError):
|
||||||
"""入参缺失 / 页面元素找不到 / 页面改版。"""
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 返回本包 examples 下默认干跑 JSON 的路径。
|
||||||
def default_contract_input_path() -> str:
|
def default_contract_input_path() -> str:
|
||||||
return str(
|
return str(
|
||||||
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
|
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 一次合同起草的入参。账密为空时由 resolve_contract_credentials 补环境变量。
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ContractCreateInput:
|
class ContractCreateInput:
|
||||||
job_id: str
|
job_id: str
|
||||||
|
|
@ -41,8 +47,8 @@ class ContractCreateInput:
|
||||||
contract_system_no: str | None = None
|
contract_system_no: str | None = None
|
||||||
attempt_no: int = 1
|
attempt_no: int = 1
|
||||||
contract_url: str = CONTRACT_URL
|
contract_url: str = CONTRACT_URL
|
||||||
username: str = "10007228"
|
username: str = ""
|
||||||
password: str = "1"
|
password: str = ""
|
||||||
submit_mode: str = "submit"
|
submit_mode: str = "submit"
|
||||||
order_no_list: tuple[str, ...] = ()
|
order_no_list: tuple[str, ...] = ()
|
||||||
template_name: str = ""
|
template_name: str = ""
|
||||||
|
|
@ -54,6 +60,24 @@ class ContractCreateInput:
|
||||||
ContractRunner = Callable[[ContractCreateInput], str]
|
ContractRunner = Callable[[ContractCreateInput], str]
|
||||||
|
|
||||||
|
|
||||||
|
# 取出合同系统账密。入参优先,否则读 CONTRACT_USERNAME / CONTRACT_PASSWORD。
|
||||||
|
#
|
||||||
|
# 用户名、密码缺一即抛 ContractCreateError。不内置测试账号。
|
||||||
|
def resolve_contract_credentials(
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
user = (username or "").strip() or (os.getenv("CONTRACT_USERNAME") or "").strip()
|
||||||
|
if password is None or password == "":
|
||||||
|
pwd = os.getenv("CONTRACT_PASSWORD") or ""
|
||||||
|
else:
|
||||||
|
pwd = str(password)
|
||||||
|
if not user or pwd == "":
|
||||||
|
raise ContractCreateError("未配置 CONTRACT_USERNAME / CONTRACT_PASSWORD")
|
||||||
|
return user, pwd
|
||||||
|
|
||||||
|
|
||||||
|
# 按入参起草合同。dry_run 不点提交;submit 必须读到合同号。
|
||||||
def execute_contract_create(
|
def execute_contract_create(
|
||||||
spec: ContractCreateInput,
|
spec: ContractCreateInput,
|
||||||
*,
|
*,
|
||||||
|
|
@ -80,8 +104,8 @@ def execute_contract_create(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 在合同系统中起草合同并返回合同编号。
|
||||||
def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
|
def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
|
||||||
"""在合同系统中起草合同并返回合同编号。"""
|
|
||||||
if spec.contract_system_no:
|
if spec.contract_system_no:
|
||||||
return spec.contract_system_no
|
return spec.contract_system_no
|
||||||
|
|
||||||
|
|
@ -95,6 +119,7 @@ def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
|
||||||
return run_contract_create_flow(browser, ctx)
|
return run_contract_create_flow(browser, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。
|
||||||
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
|
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
|
||||||
draft = contract_draft_from_dict(data)
|
draft = contract_draft_from_dict(data)
|
||||||
_validate_data(data, draft)
|
_validate_data(data, draft)
|
||||||
|
|
@ -107,8 +132,8 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
|
||||||
contract_system_no=_opt(data.get("contractSystemNo")),
|
contract_system_no=_opt(data.get("contractSystemNo")),
|
||||||
attempt_no=int(data.get("attemptNo", 1) or 1),
|
attempt_no=int(data.get("attemptNo", 1) or 1),
|
||||||
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
|
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
|
||||||
username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "10007228"),
|
username=_clean(data.get("username")),
|
||||||
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")),
|
password="" if data.get("password") in (None, "") else str(data.get("password")),
|
||||||
submit_mode=draft.submit_mode,
|
submit_mode=draft.submit_mode,
|
||||||
order_no_list=tuple(draft.order_no_list),
|
order_no_list=tuple(draft.order_no_list),
|
||||||
template_name=draft.template_name,
|
template_name=draft.template_name,
|
||||||
|
|
@ -119,12 +144,14 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
|
||||||
return replace(spec, flow_context=ctx)
|
return replace(spec, flow_context=ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# 把草稿和入参收成页面上下文。账密缺环境变量时在这里失败。
|
||||||
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
|
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
|
||||||
merged_pdf_path = _resolve_merged_pdf(draft, spec)
|
merged_pdf_path = _resolve_merged_pdf(draft, spec)
|
||||||
|
username, password = resolve_contract_credentials(spec.username, spec.password)
|
||||||
return ContractFlowContext(
|
return ContractFlowContext(
|
||||||
contract_url=spec.contract_url,
|
contract_url=spec.contract_url,
|
||||||
username=spec.username,
|
username=username,
|
||||||
password=spec.password,
|
password=password,
|
||||||
supplier_name=draft.supplier_name or spec.supplier_name,
|
supplier_name=draft.supplier_name or spec.supplier_name,
|
||||||
order_date=draft.order_date or spec.order_date,
|
order_date=draft.order_date or spec.order_date,
|
||||||
source_type=draft.source_type or spec.source_type,
|
source_type=draft.source_type or spec.source_type,
|
||||||
|
|
@ -158,6 +185,7 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# YYC 才合并平台 PDF。dry_run 不校验不上传。
|
||||||
def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None:
|
def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None:
|
||||||
if draft.source_type != "YYC":
|
if draft.source_type != "YYC":
|
||||||
return None
|
return None
|
||||||
|
|
@ -185,6 +213,7 @@ def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str
|
||||||
raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc
|
raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# 组装提交成功后的摘要字段。
|
||||||
def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
|
def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"supplierName": spec.supplier_name,
|
"supplierName": spec.supplier_name,
|
||||||
|
|
@ -197,19 +226,13 @@ def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 检查必填字段、金额和货品行。不通过则抛 ContractCreateError。
|
||||||
def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
|
def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
|
||||||
missing = []
|
missing = []
|
||||||
for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
|
for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
|
||||||
if not _str(data.get(key)):
|
if not _str(data.get(key)):
|
||||||
missing.append(key)
|
missing.append(key)
|
||||||
|
|
||||||
customer_code = draft.customer_code or _str(data.get("customerCode"))
|
|
||||||
if customer_code and is_placeholder_customer_code(customer_code):
|
|
||||||
raise ContractCreateError(
|
|
||||||
f"customerCode「{customer_code}」是占位符,合同系统客商选择窗搜不到。"
|
|
||||||
"请换成测试环境真实存在的客商操作码,例如 DW0100046361(重庆亿昊药业有限公司)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if draft.submit_mode not in ("dry_run", "submit"):
|
if draft.submit_mode not in ("dry_run", "submit"):
|
||||||
raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)")
|
raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)")
|
||||||
|
|
||||||
|
|
@ -233,6 +256,7 @@ def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
|
||||||
raise ContractCreateError("缺少必填字段:" + ", ".join(missing))
|
raise ContractCreateError("缺少必填字段:" + ", ".join(missing))
|
||||||
|
|
||||||
|
|
||||||
|
# 取出必填字符串。缺或空白则失败。
|
||||||
def _require(data: dict[str, Any], key: str) -> str:
|
def _require(data: dict[str, Any], key: str) -> str:
|
||||||
value = _str(data.get(key))
|
value = _str(data.get(key))
|
||||||
if not value:
|
if not value:
|
||||||
|
|
@ -240,17 +264,20 @@ def _require(data: dict[str, Any], key: str) -> str:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# 把值收成非空字符串,空白则 None。
|
||||||
def _opt(value: Any) -> str | None:
|
def _opt(value: Any) -> str | None:
|
||||||
value = _str(value)
|
value = _str(value)
|
||||||
return value or None
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
# 把任意值收成去两端空白的字符串。None 当空。
|
||||||
def _str(value: Any) -> str:
|
def _str(value: Any) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
return str(value).strip()
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# 去掉两端空白和成对反引号。
|
||||||
def _clean(value: Any) -> str:
|
def _clean(value: Any) -> str:
|
||||||
value = _str(value)
|
value = _str(value)
|
||||||
if len(value) >= 2 and value[0] == "`" and value[-1] == "`":
|
if len(value) >= 2 and value[0] == "`" and value[-1] == "`":
|
||||||
|
|
@ -258,8 +285,8 @@ def _clean(value: Any) -> str:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。
|
||||||
def load_input_data(raw: str) -> dict[str, Any]:
|
def load_input_data(raw: str) -> dict[str, Any]:
|
||||||
"""解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。"""
|
|
||||||
raw = (raw or "").strip()
|
raw = (raw or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
fallback = default_contract_input_path()
|
fallback = default_contract_input_path()
|
||||||
|
|
@ -280,11 +307,13 @@ def load_input_data(raw: str) -> dict[str, Any]:
|
||||||
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
|
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
|
||||||
|
|
||||||
|
|
||||||
|
# 从 JSON 字典起草合同并返回结果。
|
||||||
def run_contract_create(data: dict[str, Any]) -> dict[str, Any]:
|
def run_contract_create(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
spec = build_spec_from_data(data)
|
spec = build_spec_from_data(data)
|
||||||
return execute_contract_create(spec)
|
return execute_contract_create(spec)
|
||||||
|
|
||||||
|
|
||||||
|
# 解析命令行。账密默认读环境变量,没有内置账号。
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="合同系统页面起草。")
|
parser = argparse.ArgumentParser(description="合同系统页面起草。")
|
||||||
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
|
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
|
||||||
|
|
@ -295,13 +324,14 @@ def parse_args() -> argparse.Namespace:
|
||||||
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
|
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
|
||||||
parser.add_argument("--attempt-no", type=int, default=1)
|
parser.add_argument("--attempt-no", type=int, default=1)
|
||||||
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
|
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
|
||||||
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "10007228"))
|
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME"))
|
||||||
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD"))
|
||||||
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
||||||
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
# 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
|
|
@ -310,6 +340,7 @@ def main() -> int:
|
||||||
else:
|
else:
|
||||||
if not (args.job_id and args.supplier_name and args.order_date):
|
if not (args.job_id and args.supplier_name and args.order_date):
|
||||||
raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json")
|
raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json")
|
||||||
|
username, password = resolve_contract_credentials(args.username, args.password)
|
||||||
spec = ContractCreateInput(
|
spec = ContractCreateInput(
|
||||||
job_id=args.job_id,
|
job_id=args.job_id,
|
||||||
supplier_name=args.supplier_name,
|
supplier_name=args.supplier_name,
|
||||||
|
|
@ -318,8 +349,8 @@ def main() -> int:
|
||||||
contract_system_no=args.contract_system_no,
|
contract_system_no=args.contract_system_no,
|
||||||
attempt_no=args.attempt_no,
|
attempt_no=args.attempt_no,
|
||||||
contract_url=args.contract_url,
|
contract_url=args.contract_url,
|
||||||
username=args.username,
|
username=username,
|
||||||
password=args.password,
|
password=password,
|
||||||
draft_path=args.draft_path,
|
draft_path=args.draft_path,
|
||||||
merged_pdf_output=args.merged_pdf_output,
|
merged_pdf_output=args.merged_pdf_output,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Contract draft input model for CONTRACT_CREATE."""
|
# 合同起草入参。页面流按这份草稿填客商、金额、日期和货品行。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -8,15 +9,8 @@ from typing import Any
|
||||||
|
|
||||||
from contract_flow_steps import ContractGoodsLine
|
from contract_flow_steps import ContractGoodsLine
|
||||||
|
|
||||||
_PLACEHOLDER_CUSTOMER_PREFIXES = ("CUST-", "TEST-", "MOCK-")
|
|
||||||
|
|
||||||
|
|
||||||
def is_placeholder_customer_code(code: str) -> bool:
|
|
||||||
"""CUST-001 / TEST-xxx 这类占位符在合同系统里搜不到,不能拿去点真页面。"""
|
|
||||||
value = (code or "").strip().upper()
|
|
||||||
return value.startswith(_PLACEHOLDER_CUSTOMER_PREFIXES)
|
|
||||||
|
|
||||||
|
|
||||||
|
# 一次起草要填的字段。班次从合单来,命令行从 JSON 来。
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ContractDraft:
|
class ContractDraft:
|
||||||
supplier_name: str
|
supplier_name: str
|
||||||
|
|
@ -50,13 +44,16 @@ class ContractDraft:
|
||||||
merged_pdf_path: str | None = None
|
merged_pdf_path: str | None = None
|
||||||
contract_document_path: str = ""
|
contract_document_path: str = ""
|
||||||
order_no_list: list[str] = field(default_factory=list)
|
order_no_list: list[str] = field(default_factory=list)
|
||||||
|
lookup_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# 从 JSON 文件读起草草稿。
|
||||||
def load_contract_draft(path: str | Path) -> ContractDraft:
|
def load_contract_draft(path: str | Path) -> ContractDraft:
|
||||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
return contract_draft_from_dict(data)
|
return contract_draft_from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
# 把 JSON 字典收成起草草稿。缺的字段用页面上的默认值。
|
||||||
def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
|
def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
|
||||||
goods_lines = [
|
goods_lines = [
|
||||||
ContractGoodsLine(
|
ContractGoodsLine(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,294 @@
|
||||||
|
# 合同 S06–S08、S10:签约方、尽调、基础信息、金额日期、付款。
|
||||||
|
#
|
||||||
|
# 客商必须是合同系统真实操作码。正文含「未尽调」则本单失败,不提交。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from accept import evidence_shot
|
||||||
|
from errors import DiligenceError, NodeError, SupplierContractDisabled
|
||||||
|
|
||||||
|
from contract_common import (
|
||||||
|
CUSTOMER_PICKER,
|
||||||
|
F,
|
||||||
|
X,
|
||||||
|
BrowserAdapter,
|
||||||
|
ContractFlowContext,
|
||||||
|
_ACCEPT,
|
||||||
|
_category_option,
|
||||||
|
_expected_title,
|
||||||
|
_payment_option,
|
||||||
|
_try_read,
|
||||||
|
_yes_no,
|
||||||
|
begin_date_for,
|
||||||
|
sign_date_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 填签约方(S06):按客商操作码在弹窗里选客商并校验名称。
|
||||||
|
def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
expected_name = (ctx.customer_name or ctx.supplier_name).strip()
|
||||||
|
if not ctx.customer_code:
|
||||||
|
raise SupplierContractDisabled("客商操作码(customerCode)为空,无法加载客商")
|
||||||
|
|
||||||
|
# 点击客户名称字段旁的「选择」按钮打开客商选择窗口,按编码搜索并带回客商
|
||||||
|
browser.click_first(CUSTOMER_PICKER["trigger"])
|
||||||
|
_pick_customer_from_dialog(browser, ctx.customer_code)
|
||||||
|
loaded_name = _wait_customer_name(browser, timeout=8)
|
||||||
|
|
||||||
|
if not loaded_name:
|
||||||
|
evidence_shot(browser, "contract-customer-load-failed", failed=True)
|
||||||
|
raise SupplierContractDisabled(
|
||||||
|
f"客商加载失败,请确认输入的客商操作码是否有误:{ctx.customer_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_name and loaded_name != expected_name:
|
||||||
|
evidence_shot(browser, "contract-customer-name-mismatch", failed=True)
|
||||||
|
raise SupplierContractDisabled(
|
||||||
|
f"客商名称校验不通过:期望「{expected_name}」,实际「{loaded_name}」"
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_shot(browser, "contract-signatory-info-filled")
|
||||||
|
loaded_code = _try_read(browser, [F["customer_code"]])
|
||||||
|
_ACCEPT.check(
|
||||||
|
"S06",
|
||||||
|
"签约方",
|
||||||
|
[
|
||||||
|
("customerName", loaded_name, expected_name),
|
||||||
|
("customerCode", loaded_code, ctx.customer_code),
|
||||||
|
],
|
||||||
|
browser=browser,
|
||||||
|
screenshot_on_fail="contract-customer-name-mismatch",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 等客户名称回填。加载失败文案出现时返回空字符串。
|
||||||
|
def _wait_customer_name(browser: BrowserAdapter, timeout: int) -> str:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
body = browser.read_body_text()
|
||||||
|
if "自动加载客商失败" in body or "客商操作码是否有误" in body:
|
||||||
|
return ""
|
||||||
|
name = _try_read(browser, [F["customer_name"]])
|
||||||
|
if name and name not in ("==请选择==", "请选择"):
|
||||||
|
return name
|
||||||
|
time.sleep(0.5)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 在客商选择 iframe 里按操作码搜并勾中对应行。
|
||||||
|
def _pick_customer_from_dialog(browser: BrowserAdapter, code: str) -> None:
|
||||||
|
# 客商选择窗口是一个 iframe 弹窗:切入后搜索编码 → 按编码点对应行 → 切回主文档。
|
||||||
|
browser.wait_for_element(CUSTOMER_PICKER["frame"], timeout_seconds=10)
|
||||||
|
browser.switch_to_frame(CUSTOMER_PICKER["frame"])
|
||||||
|
try:
|
||||||
|
browser.fill_first(CUSTOMER_PICKER["search_input"], code)
|
||||||
|
browser.click_first(CUSTOMER_PICKER["search_button"])
|
||||||
|
time.sleep(1.5)
|
||||||
|
browser.wait_for_element(CUSTOMER_PICKER["result_checkbox"], timeout_seconds=10)
|
||||||
|
picked = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const code = String(arguments[0] || '');
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
for (const row of rows) {
|
||||||
|
const text = (row.innerText || '');
|
||||||
|
if (text.indexOf(code) < 0) continue;
|
||||||
|
const box = row.querySelector('input[name="List_Selected"]');
|
||||||
|
if (box) { box.click(); return {ok: true, text: text.replace(/\\s+/g, ' ').slice(0, 80)}; }
|
||||||
|
}
|
||||||
|
return {ok: false};
|
||||||
|
""",
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
if not (isinstance(picked, dict) and picked.get("ok")):
|
||||||
|
browser.click_first(CUSTOMER_PICKER["result_checkbox"])
|
||||||
|
time.sleep(1)
|
||||||
|
finally:
|
||||||
|
browser.switch_to_default_content()
|
||||||
|
|
||||||
|
|
||||||
|
# 填基础信息(S07):类别、封面模板、结算、背景、标题。
|
||||||
|
def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
if ctx.contract_category:
|
||||||
|
browser.select_xform(X["contract_category"], _category_option(ctx.contract_category))
|
||||||
|
_select_cover_template_if_needed(browser)
|
||||||
|
if ctx.settlement_type:
|
||||||
|
_select_settlement_type(browser, ctx.settlement_type)
|
||||||
|
if ctx.contract_background:
|
||||||
|
browser.fill_first([F["contract_background"]], ctx.contract_background)
|
||||||
|
# 合同性质默认主合同(页面已勾选),仅在需要从合同时切换
|
||||||
|
if ctx.contract_nature and ctx.contract_nature != "主合同":
|
||||||
|
browser.click_first(['input[name="fdType"][value="01"]'])
|
||||||
|
_fill_contract_title(browser, ctx)
|
||||||
|
evidence_shot(browser, "contract-basic-info-filled")
|
||||||
|
_ACCEPT.check(
|
||||||
|
"S07",
|
||||||
|
"基础信息",
|
||||||
|
[
|
||||||
|
("category", _read_xform_label(browser, X["contract_category"]), _category_option(ctx.contract_category) if ctx.contract_category else ""),
|
||||||
|
("settlement", _try_read(browser, [F["settlement_type"]]), ctx.settlement_type),
|
||||||
|
("background", _try_read(browser, [F["contract_background"]]), ctx.contract_background),
|
||||||
|
("title", _try_read(browser, [F["doc_subject"]]), _expected_title(ctx)),
|
||||||
|
],
|
||||||
|
browser=browser,
|
||||||
|
screenshot_on_fail="contract-basic-info-filled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 把合同标题写入 docSubject。空则按客商+类别+订单日拼。
|
||||||
|
def _fill_contract_title(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
title = (ctx.contract_title or "").strip()
|
||||||
|
if not title:
|
||||||
|
who = (ctx.customer_name or ctx.supplier_name or "").strip()
|
||||||
|
cate = (ctx.contract_category or "采购协议").strip()
|
||||||
|
date = (ctx.order_date or "").replace("-", "")
|
||||||
|
title = f"{who}{cate}{date}"
|
||||||
|
if title:
|
||||||
|
browser.fill_first([F["doc_subject"]], title)
|
||||||
|
|
||||||
|
|
||||||
|
# 封面模板未选时优先选「否」,避免原合同申请编号变必填。
|
||||||
|
def _select_cover_template_if_needed(browser: BrowserAdapter) -> None:
|
||||||
|
# 常用模板/已审核版本:优先选「否」,避免连带「原合同申请编号」变必填
|
||||||
|
current = _read_xform_label(browser, X["cover_template"])
|
||||||
|
if current and current not in ("==请选择==", "请选择"):
|
||||||
|
return
|
||||||
|
picked = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const name = arguments[0];
|
||||||
|
const flag = document.querySelector('xformflag[property="' + name + '"]');
|
||||||
|
if (!flag) return '';
|
||||||
|
const opts = Array.from(flag.querySelectorAll('.fs-option')).map((o) => ({
|
||||||
|
value: (o.getAttribute('data-value') || '').trim(),
|
||||||
|
text: ((o.querySelector('.fs-option-label') || o).textContent || '').trim(),
|
||||||
|
})).filter((o) => o.value || (o.text && o.text.indexOf('请选择') < 0));
|
||||||
|
const preferNo = opts.find((o) => o.text === '否' || o.value === '2' || o.value === '0');
|
||||||
|
const hit = preferNo || opts[0];
|
||||||
|
return hit ? (hit.text || hit.value) : '';
|
||||||
|
""",
|
||||||
|
X["cover_template"],
|
||||||
|
)
|
||||||
|
if picked:
|
||||||
|
browser.select_xform(X["cover_template"], picked)
|
||||||
|
|
||||||
|
|
||||||
|
# 读 xform fancy-select 当前可见标签。读不到返回空字符串。
|
||||||
|
def _read_xform_label(browser: BrowserAdapter, name: str) -> str:
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const flag = document.querySelector('xformflag[property="' + arguments[0] + '"]');
|
||||||
|
return flag ? ((flag.querySelector('.fs-label') || {}).textContent || '').trim() : '';
|
||||||
|
""",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 选结算类型。原生 select 未中则按可见文本点 LUI 下拉。
|
||||||
|
def _select_settlement_type(browser: BrowserAdapter, value: str) -> None:
|
||||||
|
browser.select_first([F["settlement_type"]], value)
|
||||||
|
selected = _try_read(browser, [F["settlement_type"]])
|
||||||
|
if not selected or selected in ("==请选择==", "请选择"):
|
||||||
|
# LUI 自定义下拉兜底:点击展开后按可见文本选择
|
||||||
|
browser.click_first([F["settlement_type"]])
|
||||||
|
browser.click_first([f"//option[contains(text(),'{value}')]", f"text={value}"])
|
||||||
|
selected = _try_read(browser, [F["settlement_type"]])
|
||||||
|
if not selected or selected in ("==请选择==", "请选择"):
|
||||||
|
raise NodeError(f"结算方式下拉未选中,仍停留在「==请选择==」,期望「{value}」")
|
||||||
|
|
||||||
|
|
||||||
|
# 尽调闸门。页面正文含「未尽调」则失败,不提交。
|
||||||
|
def check_customer_diligence(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
text = browser.read_body_text() or ""
|
||||||
|
if "未尽调" in text:
|
||||||
|
raise DiligenceError("客户未尽调")
|
||||||
|
|
||||||
|
|
||||||
|
# 填金额与签订日、开始日、结束日(S08)。
|
||||||
|
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
amount_type = (ctx.amount_type or "合同标的金额").strip()
|
||||||
|
if amount_type:
|
||||||
|
browser.select_xform(X["amount_type"], amount_type)
|
||||||
|
if ctx.amount:
|
||||||
|
browser.fill_first([F["amount"]], ctx.amount)
|
||||||
|
sign_date = sign_date_for(ctx)
|
||||||
|
begin_date = begin_date_for(ctx)
|
||||||
|
browser.fill_first([F["sign_date"]], sign_date)
|
||||||
|
browser.fill_first([F["begin_date"]], begin_date)
|
||||||
|
if ctx.contract_end_date:
|
||||||
|
browser.fill_first([F["end_date"]], ctx.contract_end_date)
|
||||||
|
evidence_shot(browser, "contract-amount-and-dates-filled")
|
||||||
|
_ACCEPT.check(
|
||||||
|
"S08",
|
||||||
|
"金额日期",
|
||||||
|
[
|
||||||
|
("amount", _try_read(browser, [F["amount"]]), ctx.amount),
|
||||||
|
("signDate", _try_read(browser, [F["sign_date"]]), sign_date),
|
||||||
|
("beginDate", _try_read(browser, [F["begin_date"]]), begin_date),
|
||||||
|
("endDate", _try_read(browser, [F["end_date"]]), ctx.contract_end_date),
|
||||||
|
],
|
||||||
|
browser=browser,
|
||||||
|
screenshot_on_fail="contract-amount-and-dates-filled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 填付款与标志(S10)。这期一律账期,不勾预付款。
|
||||||
|
def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
browser.select_xform(X["is_major_contract"], _yes_no(ctx.is_major_contract))
|
||||||
|
browser.select_xform(X["is_prepayment"], _yes_no(ctx.is_prepayment))
|
||||||
|
if ctx.belong_year:
|
||||||
|
browser.fill_first([F["belong_year"]], ctx.belong_year)
|
||||||
|
if ctx.payment_method:
|
||||||
|
browser.select_xform(X["payment_method"], _payment_option(ctx.payment_method))
|
||||||
|
if ctx.payment_days:
|
||||||
|
browser.fill_first([F["payment_days"]], ctx.payment_days)
|
||||||
|
if ctx.contract_status:
|
||||||
|
browser.select_xform(X["contract_status"], ctx.contract_status)
|
||||||
|
if ctx.contains_blood_products:
|
||||||
|
browser.select_xform(X["contains_blood"], _yes_no(ctx.contains_blood_products))
|
||||||
|
_fill_concession_defaults(browser)
|
||||||
|
evidence_shot(browser, "contract-payment-filled")
|
||||||
|
_ACCEPT.check(
|
||||||
|
"S10",
|
||||||
|
"付款信息",
|
||||||
|
[
|
||||||
|
("major", _read_xform_label(browser, X["is_major_contract"]), _yes_no(ctx.is_major_contract)),
|
||||||
|
("prepay", _read_xform_label(browser, X["is_prepayment"]), _yes_no(ctx.is_prepayment)),
|
||||||
|
("year", _try_read(browser, [F["belong_year"]]), ctx.belong_year),
|
||||||
|
("payMethod", _read_xform_label(browser, X["payment_method"]), _payment_option(ctx.payment_method) if ctx.payment_method else ""),
|
||||||
|
("payDays", _try_read(browser, [F["payment_days"]]), ctx.payment_days),
|
||||||
|
("status", _read_xform_label(browser, X["contract_status"]), ctx.contract_status),
|
||||||
|
],
|
||||||
|
browser=browser,
|
||||||
|
screenshot_on_fail="contract-payment-filled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 让步情形等是/否单选未填时默认点「否」,避免挡住提交。
|
||||||
|
def _fill_concession_defaults(browser: BrowserAdapter) -> None:
|
||||||
|
# 让步情形等是/否单选未填会挡住提交;默认点「否」
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const clicked = [];
|
||||||
|
document.querySelectorAll('tr').forEach((tr) => {
|
||||||
|
const radios = Array.from(tr.querySelectorAll('input[type="radio"]'));
|
||||||
|
if (radios.length < 2) return;
|
||||||
|
const labeled = radios.map((r) => ({
|
||||||
|
el: r,
|
||||||
|
text: ((r.closest('label') || r.parentElement || {}).textContent || '')
|
||||||
|
.replace(/\\s+/g, '').trim(),
|
||||||
|
}));
|
||||||
|
if (!labeled.some((x) => x.text === '是') || !labeled.some((x) => x.text === '否')) return;
|
||||||
|
if (radios.some((r) => r.checked)) return;
|
||||||
|
const no = labeled.find((x) => x.text === '否');
|
||||||
|
if (no) { no.el.click(); clicked.push(radios[0].name); }
|
||||||
|
});
|
||||||
|
return clicked;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,605 @@
|
||||||
|
# 合同 S09:货品明细。弹窗按操作码搜,找不到则本单失败。
|
||||||
|
#
|
||||||
|
# 同品种多行时先克隆身份再填数量单价,不把两行压进同一格。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from accept import evidence_shot
|
||||||
|
from errors import NodeError
|
||||||
|
|
||||||
|
from contract_common import (
|
||||||
|
ADD_ROW_SELECTORS,
|
||||||
|
GOODS_FIELD_SUFFIXES,
|
||||||
|
GOODS_PICKER,
|
||||||
|
BrowserAdapter,
|
||||||
|
ContractFlowContext,
|
||||||
|
ContractGoodsLine,
|
||||||
|
_ACCEPT,
|
||||||
|
_try_read,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 填货品明细(S09)。弹窗按操作码搜,找不到则本单失败。
|
||||||
|
def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
if not ctx.goods_lines:
|
||||||
|
evidence_shot(browser, "contract-goods-lines-filled")
|
||||||
|
_ACCEPT.note("S09", "货品明细", {"lines": 0}, ok=True)
|
||||||
|
return
|
||||||
|
used: list[int] = []
|
||||||
|
assigned: list[int] = []
|
||||||
|
for line in ctx.goods_lines:
|
||||||
|
row = _find_goods_row(browser, line, skip=used)
|
||||||
|
if row < 0:
|
||||||
|
same = _existing_used_match(browser, line, used)
|
||||||
|
if same >= 0:
|
||||||
|
row = _append_same_goods_row(browser, same)
|
||||||
|
else:
|
||||||
|
if used and not _empty_goods_row_indexes(browser):
|
||||||
|
_add_goods_row(browser)
|
||||||
|
row = _pick_goods_from_dialog(browser, line, skip=used)
|
||||||
|
_fill_goods_required_qty_price(browser, line, row)
|
||||||
|
used.append(row)
|
||||||
|
assigned.append(row)
|
||||||
|
_delete_empty_goods_rows(browser)
|
||||||
|
used = []
|
||||||
|
assigned = []
|
||||||
|
for line in ctx.goods_lines:
|
||||||
|
row = _find_goods_row(browser, line, skip=used)
|
||||||
|
assigned.append(row)
|
||||||
|
if row >= 0:
|
||||||
|
used.append(row)
|
||||||
|
evidence_shot(browser, "contract-goods-lines-filled")
|
||||||
|
for index, (line, row) in enumerate(zip(ctx.goods_lines, assigned, strict=True)):
|
||||||
|
opcode = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else ""
|
||||||
|
mdm = _try_read(browser, [_goods_selector("goods_mdm", row)]) if row >= 0 else ""
|
||||||
|
qty = _try_read(browser, [_goods_selector("goods_qty", row)]) if row >= 0 else ""
|
||||||
|
price = _try_read(browser, [_goods_selector("goods_price", row)]) if row >= 0 else ""
|
||||||
|
if line.mdm_code:
|
||||||
|
code_pair = ("goodsMdm", mdm or opcode, line.mdm_code)
|
||||||
|
else:
|
||||||
|
code_pair = ("goodsCode", opcode, line.goods_code)
|
||||||
|
_ACCEPT.check(
|
||||||
|
"S09",
|
||||||
|
f"货品明细#{index + 1}",
|
||||||
|
[
|
||||||
|
code_pair,
|
||||||
|
("qty", qty, line.quantity),
|
||||||
|
("price", price, line.unit_price or "1.00"),
|
||||||
|
],
|
||||||
|
browser=browser,
|
||||||
|
screenshot_on_fail="contract-goods-pick-failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 货品弹窗搜索关键字:MDM、操作码、名称按这个顺序取第一个非空。
|
||||||
|
def _goods_search_keyword(line: ContractGoodsLine) -> str:
|
||||||
|
return (line.mdm_code or line.goods_code or line.goods_name or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# 判断关键字是不是操作码形态(FQX/QX 开头且含数字)。
|
||||||
|
def _looks_like_goods_opcode(keyword: str) -> bool:
|
||||||
|
compact = (keyword or "").replace(" ", "").upper()
|
||||||
|
return compact.startswith(("FQX", "QX")) and any(ch.isdigit() for ch in compact)
|
||||||
|
|
||||||
|
|
||||||
|
# 把商品选择弹窗当前可见行收成短文本,失败时写进报错。
|
||||||
|
def _picker_rows_dump(browser: BrowserAdapter) -> str:
|
||||||
|
dump = browser.execute_script(
|
||||||
|
"""
|
||||||
|
return Array.from(document.querySelectorAll('tr')).map((row) => {
|
||||||
|
const text = (row.innerText || '').replace(/\\s+/g, ' ').trim();
|
||||||
|
const box = row.querySelector(
|
||||||
|
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
|
||||||
|
);
|
||||||
|
if (!box || !text) return '';
|
||||||
|
return text.slice(0, 80);
|
||||||
|
}).filter(Boolean).slice(0, 8).join('|');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return str(dump or "")
|
||||||
|
|
||||||
|
|
||||||
|
# 打开选择商品弹窗搜一行货,返回明细表里新占用的行号。
|
||||||
|
def _pick_goods_from_dialog(
|
||||||
|
browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = ()
|
||||||
|
) -> int:
|
||||||
|
keyword = _goods_search_keyword(line)
|
||||||
|
if not keyword:
|
||||||
|
raise NodeError("货品缺少 goodsMdmCode/goodsCode,无法在「选择商品」中搜索")
|
||||||
|
|
||||||
|
_select_goods_in_picker(browser, line, keyword)
|
||||||
|
row = _wait_unused_goods_row(browser, line, skip, timeout=12)
|
||||||
|
if row >= 0:
|
||||||
|
return row
|
||||||
|
_add_goods_row(browser)
|
||||||
|
_select_goods_in_picker(browser, line, keyword)
|
||||||
|
row = _wait_unused_goods_row(browser, line, skip, timeout=12)
|
||||||
|
if row >= 0:
|
||||||
|
return row
|
||||||
|
_scroll_to_goods_table(browser)
|
||||||
|
evidence_shot(browser, "contract-goods-pick-failed", failed=True)
|
||||||
|
dump = _goods_table_dump(browser)
|
||||||
|
raise NodeError(f"选择商品后未在明细表找到新的一行「{keyword}」 dump={dump}")
|
||||||
|
|
||||||
|
|
||||||
|
# 在选择商品 iframe 里搜关键字并勾中对应行,然后点确定。
|
||||||
|
def _select_goods_in_picker(browser: BrowserAdapter, line: ContractGoodsLine, keyword: str) -> None:
|
||||||
|
browser.click_first(GOODS_PICKER["trigger"])
|
||||||
|
browser.wait_for_element(GOODS_PICKER["frame"], timeout_seconds=15)
|
||||||
|
browser.switch_to_frame(GOODS_PICKER["frame"])
|
||||||
|
try:
|
||||||
|
_search_goods_in_dialog(browser, keyword)
|
||||||
|
if not _dialog_row_has_keyword(browser, keyword):
|
||||||
|
_search_goods_in_dialog(browser, keyword)
|
||||||
|
picked = _select_dialog_row_by_keyword(browser, keyword)
|
||||||
|
if not picked and line.goods_name and not _looks_like_goods_opcode(keyword):
|
||||||
|
_search_goods_in_dialog(browser, line.goods_name.strip())
|
||||||
|
picked = _select_dialog_row_by_keyword(browser, keyword)
|
||||||
|
if not picked:
|
||||||
|
picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip())
|
||||||
|
if not picked:
|
||||||
|
dump = _picker_rows_dump(browser)
|
||||||
|
raise NodeError(f"选择商品失败,未找到「{keyword}」 dump={dump}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
evidence_shot(browser, "contract-goods-picker")
|
||||||
|
except Exception as exc:
|
||||||
|
browser.switch_to_default_content()
|
||||||
|
_scroll_to_goods_table(browser)
|
||||||
|
evidence_shot(browser, "contract-goods-pick-failed", failed=True)
|
||||||
|
raise NodeError(f"选择商品失败,未找到「{keyword}」:{exc}") from exc
|
||||||
|
browser.switch_to_default_content()
|
||||||
|
_click_dialog_ok(browser)
|
||||||
|
|
||||||
|
|
||||||
|
# 在商品弹窗搜索框填关键字并点搜索,等到出现含关键字的行。
|
||||||
|
def _search_goods_in_dialog(browser: BrowserAdapter, keyword: str) -> None:
|
||||||
|
browser.fill_first(GOODS_PICKER["search_input"], keyword)
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const el = document.querySelector('input.search-box-text, input[placeholder*="关键字"]');
|
||||||
|
if (!el) return false;
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true}));
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keyup', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true}));
|
||||||
|
if (el.form) { el.form.dispatchEvent(new Event('submit', {bubbles:true, cancelable:true})); }
|
||||||
|
return true;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
browser.click_native(GOODS_PICKER["search_button"])
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
browser.click_first(GOODS_PICKER["search_button"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
deadline = time.time() + 6
|
||||||
|
while time.time() < deadline:
|
||||||
|
if _dialog_row_has_keyword(browser, keyword):
|
||||||
|
return
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
|
||||||
|
# 商品弹窗表格里是否已有含该关键字且带勾选框的行。
|
||||||
|
def _dialog_row_has_keyword(browser: BrowserAdapter, keyword: str) -> bool:
|
||||||
|
result = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
|
||||||
|
if (!keyword) return false;
|
||||||
|
return Array.from(document.querySelectorAll('tr')).some((row) => {
|
||||||
|
const text = (row.innerText || '').replace(/\\s+/g, '');
|
||||||
|
return text.indexOf(keyword) >= 0 && !!row.querySelector(
|
||||||
|
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
""",
|
||||||
|
keyword,
|
||||||
|
)
|
||||||
|
return result is True
|
||||||
|
|
||||||
|
|
||||||
|
# 按关键字勾中商品弹窗里对应行。勾到返回 True。
|
||||||
|
def _select_dialog_row_by_keyword(browser: BrowserAdapter, keyword: str) -> bool:
|
||||||
|
picked = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
|
||||||
|
if (!keyword) return {ok: false};
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
for (const row of rows) {
|
||||||
|
const text = (row.innerText || '').replace(/\\s+/g, '');
|
||||||
|
if (text.indexOf(keyword) < 0) continue;
|
||||||
|
const box = row.querySelector(
|
||||||
|
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
|
||||||
|
);
|
||||||
|
if (box) {
|
||||||
|
if (!box.checked) { box.click(); }
|
||||||
|
return {ok: true, text: (row.innerText || '').replace(/\\s+/g, ' ').slice(0, 80)};
|
||||||
|
}
|
||||||
|
row.click();
|
||||||
|
return {ok: true, text: 'row-click'};
|
||||||
|
}
|
||||||
|
return {ok: false, rows: rows.length};
|
||||||
|
""",
|
||||||
|
keyword,
|
||||||
|
)
|
||||||
|
return isinstance(picked, dict) and bool(picked.get("ok"))
|
||||||
|
|
||||||
|
|
||||||
|
# 把「选择商品」滚进视口,方便后续点添加行。
|
||||||
|
def _scroll_to_goods_table(browser: BrowserAdapter) -> None:
|
||||||
|
try:
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const hit = document.querySelector('div[title="选择商品"]')
|
||||||
|
|| Array.from(document.querySelectorAll('div, a, span, button'))
|
||||||
|
.find((el) => (el.textContent || '').trim() === '选择商品');
|
||||||
|
if (hit) hit.scrollIntoView({block: 'center'});
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
time.sleep(0.3)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 点商品弹窗的「确定」。原生点不到就走脚本。
|
||||||
|
def _click_dialog_ok(browser: BrowserAdapter) -> None:
|
||||||
|
try:
|
||||||
|
browser.click_native(
|
||||||
|
[
|
||||||
|
"//*[contains(@class,'lui_dialog')]//*[normalize-space()='确定']",
|
||||||
|
".lui_dialog_main div.lui_widget_btn_txt",
|
||||||
|
"text=确定",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const root = document.querySelector('.lui_dialog_main') || document;
|
||||||
|
const btns = Array.from(root.querySelectorAll('div, a, span, button')).filter((el) => {
|
||||||
|
const text = (el.textContent || '').trim();
|
||||||
|
return text === '确定' && el.children.length === 0;
|
||||||
|
});
|
||||||
|
if (!btns.length) return {ok: false};
|
||||||
|
btns[btns.length - 1].click();
|
||||||
|
return {ok: true};
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if isinstance(result, dict) and result.get("ok"):
|
||||||
|
return
|
||||||
|
browser.click_first([".lui_dialog_main div.lui_widget_btn_txt", "text=确定"])
|
||||||
|
|
||||||
|
|
||||||
|
# 把脚本返回的行号收成 int 列表。字符串按逗号拆。
|
||||||
|
def _as_index_list(value) -> list[int]:
|
||||||
|
if value is None or value is False:
|
||||||
|
return []
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return []
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return [int(value)]
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts = [part.strip() for part in value.replace(";", ",").split(",") if part.strip()]
|
||||||
|
out: list[int] = []
|
||||||
|
for part in parts:
|
||||||
|
try:
|
||||||
|
out.append(int(float(part)))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
seq = list(value)
|
||||||
|
except TypeError:
|
||||||
|
return []
|
||||||
|
out: list[int] = []
|
||||||
|
for item in seq:
|
||||||
|
try:
|
||||||
|
out.append(int(item))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# 从匹配行号里取第一个不在 skip 里的。没有则返回 -1。
|
||||||
|
def _next_unused_goods_row(matches: list[int], skip: list[int] | tuple[int, ...] = ()) -> int:
|
||||||
|
used = {int(i) for i in skip}
|
||||||
|
for row in matches:
|
||||||
|
if row not in used:
|
||||||
|
return row
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
# 在明细表里按 MDM/操作码找尚未占用的行。找不到返回 -1。
|
||||||
|
def _find_goods_row(
|
||||||
|
browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = ()
|
||||||
|
) -> int:
|
||||||
|
row = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const keys = [String(arguments[0] || '').trim(), String(arguments[1] || '').trim()].filter(Boolean);
|
||||||
|
const skip = {};
|
||||||
|
String(arguments[2] || '').split(',').forEach((part) => {
|
||||||
|
if (part !== '') skip[Number(part)] = true;
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (skip[i]) continue;
|
||||||
|
const fields = ['fd_opcode', 'fd_mdmCode'];
|
||||||
|
const values = fields.map((suffix) => {
|
||||||
|
const el = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.' + suffix + ')"]'
|
||||||
|
);
|
||||||
|
return el ? String(el.value || '').trim() : '';
|
||||||
|
}).filter(Boolean);
|
||||||
|
if (!values.length) continue;
|
||||||
|
for (const keyword of keys) {
|
||||||
|
if (values.some((v) => v === keyword)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
""",
|
||||||
|
line.mdm_code or line.goods_code,
|
||||||
|
line.goods_name,
|
||||||
|
",".join(str(int(i)) for i in skip),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(row)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
# 把前 10 行明细的 MDM/数量/单价收成短文本,失败时写进报错。
|
||||||
|
def _goods_table_dump(browser: BrowserAdapter) -> str:
|
||||||
|
dump = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const mdm = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||||||
|
);
|
||||||
|
const qty = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsqty)"]'
|
||||||
|
);
|
||||||
|
const price = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_unitprice)"]'
|
||||||
|
);
|
||||||
|
if (!mdm && !qty && !price) continue;
|
||||||
|
out.push(
|
||||||
|
String(i) + ':' + (mdm ? String(mdm.value || '') : '') +
|
||||||
|
':' + (qty ? String(qty.value || '') : '') +
|
||||||
|
':' + (price ? String(price.value || '') : '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out.join('|');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return str(dump or "")
|
||||||
|
|
||||||
|
|
||||||
|
# 等到明细表出现该货的新行。超时返回 -1。
|
||||||
|
def _wait_unused_goods_row(
|
||||||
|
browser: BrowserAdapter,
|
||||||
|
line: ContractGoodsLine,
|
||||||
|
skip: list[int] | tuple[int, ...],
|
||||||
|
timeout: float,
|
||||||
|
) -> int:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while True:
|
||||||
|
row = _find_goods_row(browser, line, skip=skip)
|
||||||
|
if row >= 0:
|
||||||
|
return row
|
||||||
|
if time.time() >= deadline:
|
||||||
|
return -1
|
||||||
|
_click_dialog_ok(browser)
|
||||||
|
time.sleep(0.6)
|
||||||
|
|
||||||
|
|
||||||
|
# 返回明细表里身份字段都空的行号。
|
||||||
|
def _empty_goods_row_indexes(browser: BrowserAdapter) -> list[int]:
|
||||||
|
rows = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const found = [];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const code = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'
|
||||||
|
);
|
||||||
|
const name = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsname)"]'
|
||||||
|
);
|
||||||
|
const mdm = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||||||
|
);
|
||||||
|
if (!code && !name && !mdm) continue;
|
||||||
|
const empty = !(code && String(code.value || '').trim())
|
||||||
|
&& !(name && String(name.value || '').trim())
|
||||||
|
&& !(mdm && String(mdm.value || '').trim());
|
||||||
|
if (empty) found.push(i);
|
||||||
|
}
|
||||||
|
return found.join(',');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return _as_index_list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# 返回明细表里已填 MDM 或操作码的行号。
|
||||||
|
def _occupied_goods_row_indexes(browser: BrowserAdapter) -> list[int]:
|
||||||
|
rows = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const found = [];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const mdm = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||||||
|
);
|
||||||
|
const code = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'
|
||||||
|
);
|
||||||
|
const filled = (mdm && String(mdm.value || '').trim())
|
||||||
|
|| (code && String(code.value || '').trim());
|
||||||
|
if (filled) found.push(i);
|
||||||
|
}
|
||||||
|
return found.join(',');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return _as_index_list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# 点「添加行」或调 DocList_AddRow,给同品种再占一行。
|
||||||
|
def _add_goods_row(browser: BrowserAdapter) -> None:
|
||||||
|
before = _occupied_goods_row_indexes(browser)
|
||||||
|
_scroll_to_goods_table(browser)
|
||||||
|
result = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const wanted = ['添加行', '新增明细', '新增行'];
|
||||||
|
const nodes = Array.from(document.querySelectorAll('a, div, span, button, input'));
|
||||||
|
for (const el of nodes) {
|
||||||
|
const text = (el.value || el.textContent || '').replace(/\\s+/g, '').trim();
|
||||||
|
if (!wanted.includes(text)) continue;
|
||||||
|
if (el.children && el.children.length > 2) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width < 2 || rect.height < 2) continue;
|
||||||
|
el.click();
|
||||||
|
return {ok: true, via: text};
|
||||||
|
}
|
||||||
|
const tables = Array.from(document.querySelectorAll('table[id]')).map((t) => t.id);
|
||||||
|
if (typeof DocList_AddRow === 'function') {
|
||||||
|
const hit = tables.find((id) =>
|
||||||
|
id.toLowerCase().indexOf('detail') >= 0 || id.indexOf('DocList') >= 0
|
||||||
|
);
|
||||||
|
if (hit) {
|
||||||
|
DocList_AddRow(hit);
|
||||||
|
return {ok: true, via: 'DocList:' + hit};
|
||||||
|
}
|
||||||
|
DocList_AddRow();
|
||||||
|
return {ok: true, via: 'DocList()'};
|
||||||
|
}
|
||||||
|
return {ok: false, tables: tables.slice(0, 8).join(',')};
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline:
|
||||||
|
if _empty_goods_row_indexes(browser):
|
||||||
|
return
|
||||||
|
if len(_occupied_goods_row_indexes(browser)) > len(before):
|
||||||
|
return
|
||||||
|
time.sleep(0.3)
|
||||||
|
if isinstance(result, dict) and not result.get("ok"):
|
||||||
|
raise NodeError(f"同品种需要新的明细行,未找到「添加行」 tables={result.get('tables')}")
|
||||||
|
try:
|
||||||
|
browser.click_native(ADD_ROW_SELECTORS)
|
||||||
|
except Exception:
|
||||||
|
browser.click_first(ADD_ROW_SELECTORS)
|
||||||
|
time.sleep(0.5)
|
||||||
|
if _empty_goods_row_indexes(browser):
|
||||||
|
return
|
||||||
|
dump = _goods_table_dump(browser)
|
||||||
|
raise NodeError(f"同品种需要新的明细行,点击「添加行」后没有空行 dump={dump}")
|
||||||
|
|
||||||
|
|
||||||
|
# 在已经填过的行里找同一货品,供同品种追加新行时克隆身份。
|
||||||
|
def _existing_used_match(
|
||||||
|
browser: BrowserAdapter, line: ContractGoodsLine, used: list[int]
|
||||||
|
) -> int:
|
||||||
|
key = (line.mdm_code or line.goods_code or "").strip()
|
||||||
|
name = (line.goods_name or "").strip()
|
||||||
|
for row in used:
|
||||||
|
blob = " ".join(
|
||||||
|
value
|
||||||
|
for value in (
|
||||||
|
_try_read(browser, [_goods_selector("goods_mdm", row)]),
|
||||||
|
_try_read(browser, [_goods_selector("goods_code", row)]),
|
||||||
|
_try_read(browser, [_goods_selector("goods_name", row)]),
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
if key and key in blob:
|
||||||
|
return row
|
||||||
|
if name and name in blob:
|
||||||
|
return row
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
# 给已有货品克隆一行空明细,返回新行号。
|
||||||
|
def _append_same_goods_row(browser: BrowserAdapter, src_row: int) -> int:
|
||||||
|
empties = _empty_goods_row_indexes(browser)
|
||||||
|
if not empties:
|
||||||
|
_add_goods_row(browser)
|
||||||
|
empties = _empty_goods_row_indexes(browser)
|
||||||
|
if not empties:
|
||||||
|
dump = _goods_table_dump(browser)
|
||||||
|
raise NodeError(f"同品种需要新的明细行,添加行后仍没有空行 dump={dump}")
|
||||||
|
dest = empties[0]
|
||||||
|
_clone_goods_identity(browser, src_row, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
# 把源行的操作码、名称、MDM、批准文号、规格、单位拷到目标行。
|
||||||
|
def _clone_goods_identity(browser: BrowserAdapter, src_row: int, dest_row: int) -> None:
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const src = Number(arguments[0]);
|
||||||
|
const dest = Number(arguments[1]);
|
||||||
|
const fields = [
|
||||||
|
'fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno',
|
||||||
|
'fd_goodstype', 'fd_goodsunit'
|
||||||
|
];
|
||||||
|
for (const field of fields) {
|
||||||
|
const from = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + src + '.' + field + ')"]'
|
||||||
|
);
|
||||||
|
const to = document.querySelector(
|
||||||
|
'input[name="extendDataFormInfo.value(fd_detail1.' + dest + '.' + field + ')"]'
|
||||||
|
);
|
||||||
|
if (!from || !to) continue;
|
||||||
|
to.value = from.value;
|
||||||
|
to.dispatchEvent(new Event('input', {bubbles: true}));
|
||||||
|
to.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
src_row,
|
||||||
|
dest_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 往指定明细行填数量和单价。单价空则写 1.00。
|
||||||
|
def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine, row: int) -> None:
|
||||||
|
if row < 0:
|
||||||
|
raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」")
|
||||||
|
qty = str(line.quantity if line.quantity else "")
|
||||||
|
if qty:
|
||||||
|
browser.fill_first([_goods_selector("goods_qty", row)], qty)
|
||||||
|
price = (line.unit_price or "").strip() or "1.00"
|
||||||
|
browser.fill_first([_goods_selector("goods_price", row)], price)
|
||||||
|
|
||||||
|
|
||||||
|
# 勾掉身份为空的明细行并点删除行。
|
||||||
|
def _delete_empty_goods_rows(browser: BrowserAdapter) -> None:
|
||||||
|
marked = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const marked = [];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const code = document.querySelector('input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]');
|
||||||
|
const name = document.querySelector('input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsname)"]');
|
||||||
|
if (!code && !name) continue;
|
||||||
|
const empty = !(code && String(code.value || '').trim()) && !(name && String(name.value || '').trim());
|
||||||
|
if (!empty) continue;
|
||||||
|
const row = (code || name).closest('tr');
|
||||||
|
const box = row ? row.querySelector('input[type="checkbox"]') : null;
|
||||||
|
if (box && !box.checked) { box.click(); }
|
||||||
|
marked.push(i);
|
||||||
|
}
|
||||||
|
return marked;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if marked:
|
||||||
|
try:
|
||||||
|
browser.click_first(["text=删除行", "a:contains(删除行)"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 拼第 row_index 行某个货品字段的 input name 选择器。
|
||||||
|
def _goods_selector(field_key: str, row_index: int) -> str:
|
||||||
|
suffix = GOODS_FIELD_SUFFIXES[field_key]
|
||||||
|
return f'input[name="extendDataFormInfo.value(fd_detail1.{row_index}.{suffix})"]'
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
# 合同 S02–S05:打开系统、登录、直达起草表单。
|
||||||
|
#
|
||||||
|
# 不点 SPA 菜单。登录后走 FORM_PAGE_URL。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from accept import evidence_shot
|
||||||
|
from errors import LoginFailed
|
||||||
|
|
||||||
|
from contract_common import (
|
||||||
|
FORM_PAGE_URL,
|
||||||
|
BrowserAdapter,
|
||||||
|
ContractFlowContext,
|
||||||
|
_ACCEPT,
|
||||||
|
_is_logged_in,
|
||||||
|
contract_version_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 打开合同系统首页(S02)。
|
||||||
|
def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
browser.open_url(ctx.contract_url)
|
||||||
|
_ACCEPT.note("S02", "打开合同系统", {"url": ctx.contract_url}, ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# 登录合同系统(S03)。已登录则跳过填表。
|
||||||
|
def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
try:
|
||||||
|
if _is_logged_in(browser):
|
||||||
|
evidence_shot(browser, "contract-already-logged-in")
|
||||||
|
_ACCEPT.note("S03", "登录", {"user": ctx.username, "via": "already"}, ok=True)
|
||||||
|
return
|
||||||
|
browser.wait_for_element(
|
||||||
|
['input[name="j_username"]', 'input[placeholder="用户名"]'],
|
||||||
|
timeout_seconds=15,
|
||||||
|
)
|
||||||
|
browser.fill_first(['input[name="j_username"]', 'input[placeholder="用户名"]'], ctx.username)
|
||||||
|
browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password)
|
||||||
|
browser.click_first(['a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'])
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while time.time() < deadline:
|
||||||
|
if _is_logged_in(browser):
|
||||||
|
evidence_shot(browser, "contract-login-ok")
|
||||||
|
_ACCEPT.note("S03", "登录", {"user": ctx.username, "portal": "yes"}, ok=True)
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise TimeoutError("登录后未进入门户(未看到切换门户/待办/待阅)")
|
||||||
|
except Exception as exc:
|
||||||
|
evidence_shot(browser, "contract-login-failed", failed=True)
|
||||||
|
_ACCEPT.note("S03", "登录", {"user": ctx.username, "error": exc}, ok=False, raise_error=False)
|
||||||
|
raise LoginFailed(f"合同系统登录失败:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# 直达起草表单页(S04),不点 SPA 菜单。
|
||||||
|
def enter_contract_create(browser: BrowserAdapter) -> None:
|
||||||
|
# 登录后直接导航到起草表单页,跳过模板选择(前端委托事件 Selenium 点击无效)
|
||||||
|
reopen = getattr(browser, "reopen_url", None)
|
||||||
|
if callable(reopen):
|
||||||
|
reopen(FORM_PAGE_URL)
|
||||||
|
else:
|
||||||
|
browser.open_url(FORM_PAGE_URL)
|
||||||
|
browser.wait_for_text("客户编码", timeout_seconds=20)
|
||||||
|
on_form = "客户编码" in (browser.read_body_text() or "")
|
||||||
|
_ACCEPT.check(
|
||||||
|
"S04",
|
||||||
|
"进入起草",
|
||||||
|
[("formMarker", "客户编码" if on_form else (browser.read_body_text() or "")[:40], "客户编码")],
|
||||||
|
browser=browser,
|
||||||
|
screenshot_on_fail="contract-login-ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 确认已在指定模板的表单页(S05)。未进表单则再导航一次。
|
||||||
|
def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
# 已通过 URL 直接指定模板,本步骤仅兜底:若尚未进入表单则再次导航
|
||||||
|
if "客户编码" not in (browser.read_body_text() or ""):
|
||||||
|
browser.open_url(FORM_PAGE_URL)
|
||||||
|
browser.wait_for_text("客户编码", timeout_seconds=20)
|
||||||
|
on_form = "客户编码" in (browser.read_body_text() or "")
|
||||||
|
_ACCEPT.check(
|
||||||
|
"S05",
|
||||||
|
"选择模板",
|
||||||
|
[
|
||||||
|
("formMarker", "客户编码" if on_form else "", "客户编码"),
|
||||||
|
("templateType", contract_version_label(ctx), contract_version_label(ctx)),
|
||||||
|
],
|
||||||
|
browser=browser,
|
||||||
|
screenshot_on_fail="contract-login-ok",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,606 @@
|
||||||
|
# 合同 S13:按 fdId 打开详情读正式合同号。
|
||||||
|
#
|
||||||
|
# fdId 优先于标题+客户编码。禁止盲取「我的合同」最新一条。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from contract_common import (
|
||||||
|
BrowserAdapter,
|
||||||
|
ContractFlowContext,
|
||||||
|
_expected_title,
|
||||||
|
_is_logged_in,
|
||||||
|
_open_submitted_view,
|
||||||
|
_read_document_id,
|
||||||
|
_usable_contract_no,
|
||||||
|
)
|
||||||
|
from contract_login import login_contract_system
|
||||||
|
|
||||||
|
|
||||||
|
# 读正式合同号(S13)。没有 fdId 则返回空字符串。
|
||||||
|
def read_contract_number(
|
||||||
|
browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
|
||||||
|
) -> str:
|
||||||
|
no, _via = _read_contract_number_routed(browser, ctx, fd_id=fd_id)
|
||||||
|
return no
|
||||||
|
|
||||||
|
|
||||||
|
# 按 fdId 打开详情读号。读不到返回空,不回列表盲取最新一条。
|
||||||
|
def _read_contract_number_routed(
|
||||||
|
browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
del ctx
|
||||||
|
doc_id = (fd_id or "").strip() or _read_document_id(browser)
|
||||||
|
if not doc_id:
|
||||||
|
print("S13 没有 fdId,无法打开详情", flush=True)
|
||||||
|
return "", "none"
|
||||||
|
print(f"S13:开详情读号 fdId={doc_id}", flush=True)
|
||||||
|
no = _read_number_by_fd_id(browser, doc_id)
|
||||||
|
if no:
|
||||||
|
return no, "docNumber"
|
||||||
|
print("S13 详情未读到合同编号", flush=True)
|
||||||
|
return "", "none"
|
||||||
|
|
||||||
|
|
||||||
|
# 从详情页 #_xform_docNumber 读合同编号。占位符和手机号不算。
|
||||||
|
def _read_contract_number_on_page(browser: BrowserAdapter) -> str:
|
||||||
|
try:
|
||||||
|
browser.switch_to_default_content()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
scanned = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const skip = ['提交时自动生成', '自动生成', '==请选择==', '合同编号', '合同标题'];
|
||||||
|
function usable(raw) {
|
||||||
|
const v = String(raw || '').replace(/\\s+/g, ' ').trim();
|
||||||
|
if (!v || skip.indexOf(v) >= 0) return '';
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
function fromDoc(doc) {
|
||||||
|
const node = doc.querySelector('#_xform_docNumber');
|
||||||
|
if (node) {
|
||||||
|
const typed = node.querySelector('input, textarea');
|
||||||
|
const v = usable(typed ? typed.value : (node.innerText || node.textContent || ''));
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
const input = doc.querySelector('input[name="docNumber"]');
|
||||||
|
if (input) {
|
||||||
|
const v = usable(input.value || '');
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function walk(win) {
|
||||||
|
try {
|
||||||
|
const no = fromDoc(win.document);
|
||||||
|
if (no) return no;
|
||||||
|
} catch (e) {}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try {
|
||||||
|
const no = walk(win.frames[i]);
|
||||||
|
if (no) return no;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return walk(window);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if isinstance(scanned, str) and _usable_contract_no(scanned.strip()):
|
||||||
|
return scanned.strip()
|
||||||
|
if scanned:
|
||||||
|
print(f"S13 详情页扫到不可用值={scanned!r}", flush=True)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 把当前页导航回合同系统主界面,等到出现「我的合同」。
|
||||||
|
def _reload_contract_home(browser: BrowserAdapter, home: str) -> bool:
|
||||||
|
try:
|
||||||
|
browser.switch_to_default_content()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("刷新回合同主界面:", home, flush=True)
|
||||||
|
try:
|
||||||
|
browser.execute_script(
|
||||||
|
"try { window.top.location.href = arguments[0]; } catch (e) { location.href = arguments[0]; } return true;",
|
||||||
|
home,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
browser.open_url(home)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"open_url 主界面失败:{exc}", flush=True)
|
||||||
|
return _wait_text_in_frames(browser, "我的合同", timeout_seconds=12)
|
||||||
|
|
||||||
|
|
||||||
|
# 刷新主界面失败时关页重开;掉登录则再登一次。
|
||||||
|
def _reopen_contract_home(browser: BrowserAdapter, ctx: ContractFlowContext, home: str) -> bool:
|
||||||
|
print("主界面刷新失败,关闭当前页后重开", flush=True)
|
||||||
|
reopen = getattr(browser, "reopen_url", None)
|
||||||
|
if callable(reopen):
|
||||||
|
reopen(home)
|
||||||
|
else:
|
||||||
|
browser.open_url(home)
|
||||||
|
if _wait_text_in_frames(browser, "我的合同", timeout_seconds=12):
|
||||||
|
return True
|
||||||
|
if not _is_logged_in(browser):
|
||||||
|
login_contract_system(browser, ctx)
|
||||||
|
browser.open_url(home)
|
||||||
|
return _wait_text_in_frames(browser, "我的合同", timeout_seconds=12)
|
||||||
|
|
||||||
|
|
||||||
|
# 回到主界面并点开「我的合同 / 我起草的」。
|
||||||
|
def _open_my_contracts(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
home = (ctx.contract_url or "").strip() or "http://contract-test.gzmpc.com:8080/"
|
||||||
|
if not _reload_contract_home(browser, home):
|
||||||
|
_reopen_contract_home(browser, ctx, home)
|
||||||
|
if not _click_my_contracts_sidebar(browser):
|
||||||
|
print("未点到侧栏「我的合同」,关闭页面后重开", flush=True)
|
||||||
|
_reopen_contract_home(browser, ctx, home)
|
||||||
|
_click_my_contracts_sidebar(browser)
|
||||||
|
_wait_text_in_frames(browser, "我起草的", timeout_seconds=10)
|
||||||
|
_click_by_exact_text(browser, "我起草的")
|
||||||
|
_wait_text_in_frames(browser, "申请时间", timeout_seconds=10)
|
||||||
|
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
|
||||||
|
|
||||||
|
|
||||||
|
# 点侧栏「我的合同」。点到后等到「我起草的」。
|
||||||
|
def _click_my_contracts_sidebar(browser: BrowserAdapter) -> bool:
|
||||||
|
clicked = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const label = '我的合同';
|
||||||
|
function clickIn(win) {
|
||||||
|
const nodes = Array.from(win.document.querySelectorAll('a, span, div, li, p, em, td'));
|
||||||
|
const hits = nodes.filter((el) => (el.textContent || '').trim() === label);
|
||||||
|
hits.sort((a, b) => {
|
||||||
|
const ax = a.getBoundingClientRect().left;
|
||||||
|
const bx = b.getBoundingClientRect().left;
|
||||||
|
if (ax !== bx) return ax - bx;
|
||||||
|
return a.children.length - b.children.length;
|
||||||
|
});
|
||||||
|
if (hits.length) { hits[0].click(); return true; }
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try { if (clickIn(win.frames[i])) return true; } catch (e) {}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return clickIn(window);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if clicked is True:
|
||||||
|
return _wait_text_in_frames(browser, "我起草的", timeout_seconds=8)
|
||||||
|
_click_by_exact_text(browser, "我的合同")
|
||||||
|
return _wait_text_in_frames(browser, "我起草的", timeout_seconds=8)
|
||||||
|
|
||||||
|
|
||||||
|
# 在当前页和 iframe 里等到出现指定文本。超时返回 False。
|
||||||
|
def _wait_text_in_frames(browser: BrowserAdapter, text: str, timeout_seconds: int = 15) -> bool:
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
while time.time() < deadline:
|
||||||
|
if _page_has_text(browser, text):
|
||||||
|
return True
|
||||||
|
time.sleep(0.3)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 当前页或任一 iframe 正文是否含有指定文本。
|
||||||
|
def _page_has_text(browser: BrowserAdapter, text: str) -> bool:
|
||||||
|
found = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const wanted = String(arguments[0] || '');
|
||||||
|
function hasTextInFrames(win) {
|
||||||
|
try {
|
||||||
|
const body = (win.document && win.document.body && win.document.body.innerText) || '';
|
||||||
|
if (body.indexOf(wanted) >= 0) return true;
|
||||||
|
} catch (e) {}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try { if (hasTextInFrames(win.frames[i])) return true; } catch (e) {}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hasTextInFrames(window);
|
||||||
|
""",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return found is True
|
||||||
|
|
||||||
|
|
||||||
|
# 按可见文本精确点击。叶子节点优先。
|
||||||
|
def _click_by_exact_text(browser: BrowserAdapter, label: str) -> None:
|
||||||
|
clicked = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const label = String(arguments[0] || '');
|
||||||
|
function clickIn(doc) {
|
||||||
|
const nodes = Array.from(doc.querySelectorAll('div, a, span, li, p, em, td, th, button'));
|
||||||
|
const leaf = nodes.find((el) => (el.textContent || '').trim() === label && el.children.length === 0);
|
||||||
|
if (leaf) { leaf.click(); return true; }
|
||||||
|
const parent = nodes.find((el) => (el.textContent || '').trim() === label);
|
||||||
|
if (parent) { parent.click(); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function clickInFrames(win) {
|
||||||
|
try { if (clickIn(win.document)) return true; } catch (e) {}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try { if (clickInFrames(win.frames[i])) return true; } catch (e) {}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return clickInFrames(window) ? label : '';
|
||||||
|
""",
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
if clicked != label:
|
||||||
|
try:
|
||||||
|
browser.click_first([f"text={label}"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 按申请时间排序「我的合同」列表。
|
||||||
|
def _sort_my_contracts_by_apply_time(browser: BrowserAdapter) -> None:
|
||||||
|
_click_by_exact_text(browser, "申请时间")
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const latestByApplyTime = true;
|
||||||
|
function textOf(el) { return (el.textContent || '').replace(/\\s+/g, ' ').trim(); }
|
||||||
|
function sortIn(doc) {
|
||||||
|
const desc = Array.from(doc.querySelectorAll('[title], a, span, i, em')).find((el) => {
|
||||||
|
const t = (el.getAttribute('title') || el.textContent || '').trim();
|
||||||
|
return t === '降序' || t === '从晚到早' || t === '倒序';
|
||||||
|
});
|
||||||
|
if (desc) { desc.click(); return 'desc'; }
|
||||||
|
const bar = Array.from(doc.querySelectorAll('div, span')).find((el) => {
|
||||||
|
const t = textOf(el);
|
||||||
|
return t.indexOf('更多排序') >= 0 && t.length < 80;
|
||||||
|
});
|
||||||
|
if (bar) {
|
||||||
|
const downs = Array.from(bar.querySelectorAll('i, em, span, a')).filter((el) => {
|
||||||
|
const t = (el.textContent || '').trim();
|
||||||
|
const cls = String(el.className || '');
|
||||||
|
return t === '∨' || t === 'v' || t === '↓' || t === '▼'
|
||||||
|
|| cls.indexOf('down') >= 0 || cls.indexOf('desc') >= 0;
|
||||||
|
});
|
||||||
|
if (downs.length) { downs[downs.length - 1].click(); return 'arrow'; }
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function walk(win) {
|
||||||
|
try {
|
||||||
|
const hit = sortIn(win.document);
|
||||||
|
if (hit) return hit;
|
||||||
|
} catch (e) {}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try {
|
||||||
|
const hit = walk(win.frames[i]);
|
||||||
|
if (hit) return hit;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return walk(window) || (latestByApplyTime ? 'apply-time' : '');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
|
||||||
|
|
||||||
|
|
||||||
|
# 刮「我的合同」第一行可用合同号。给排查用,正式读号不走这里。
|
||||||
|
def _scrape_latest_my_contract_number(browser: BrowserAdapter) -> str:
|
||||||
|
scanned = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const latestByApplyTime = true;
|
||||||
|
const skip = ['提交时自动生成', '自动生成', '==请选择=='];
|
||||||
|
function scrape(doc) {
|
||||||
|
const rows = Array.from(doc.querySelectorAll('tr'));
|
||||||
|
let headerIdx = null;
|
||||||
|
for (const tr of rows) {
|
||||||
|
const cells = Array.from(tr.querySelectorAll('th, td')).map((c) =>
|
||||||
|
(c.innerText || '').replace(/\\s+/g, ' ').trim()
|
||||||
|
);
|
||||||
|
if (!headerIdx && cells.indexOf('合同编号') >= 0) {
|
||||||
|
headerIdx = {};
|
||||||
|
cells.forEach((h, i) => { if (h && headerIdx[h] === undefined) headerIdx[h] = i; });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!headerIdx) continue;
|
||||||
|
const no = (cells[headerIdx['合同编号']] || '').trim();
|
||||||
|
if (!no || skip.indexOf(no) >= 0) continue;
|
||||||
|
return latestByApplyTime ? no : no;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function walk(win) {
|
||||||
|
try {
|
||||||
|
const no = scrape(win.document);
|
||||||
|
if (no) return no;
|
||||||
|
} catch (e) {}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try {
|
||||||
|
const no = walk(win.frames[i]);
|
||||||
|
if (no) return no;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return walk(window);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if isinstance(scanned, str) and _usable_contract_no(scanned.strip()):
|
||||||
|
return scanned.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 从列表行里按 fdId、标题+客户编码挑合同号。fdId 优先。
|
||||||
|
def _pick_contract_row(
|
||||||
|
rows: list[dict],
|
||||||
|
*,
|
||||||
|
fd_id: str = "",
|
||||||
|
title: str = "",
|
||||||
|
customer_code: str = "",
|
||||||
|
) -> str:
|
||||||
|
fd_id = (fd_id or "").strip()
|
||||||
|
title = (title or "").strip()
|
||||||
|
customer_code = (customer_code or "").strip()
|
||||||
|
if fd_id:
|
||||||
|
for row in rows:
|
||||||
|
href = str(row.get("href") or "")
|
||||||
|
no = str(row.get("no") or "").strip()
|
||||||
|
if fd_id in href and _usable_contract_no(no):
|
||||||
|
return no
|
||||||
|
if title and customer_code:
|
||||||
|
for row in rows:
|
||||||
|
row_title = str(row.get("title") or "")
|
||||||
|
row_cust = str(row.get("customer") or "")
|
||||||
|
no = str(row.get("no") or "").strip()
|
||||||
|
if title in row_title and customer_code in row_cust and _usable_contract_no(no):
|
||||||
|
return no
|
||||||
|
if title:
|
||||||
|
for row in rows:
|
||||||
|
no = str(row.get("no") or "").strip()
|
||||||
|
if title in str(row.get("title") or "") and _usable_contract_no(no):
|
||||||
|
return no
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 在「我的合同」查询里填标题和客户编码并点搜索。
|
||||||
|
def _search_my_contracts(browser: BrowserAdapter, title: str, customer_code: str) -> None:
|
||||||
|
title = (title or "").strip()
|
||||||
|
customer_code = (customer_code or "").strip()
|
||||||
|
if not title and not customer_code:
|
||||||
|
return
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const title = String(arguments[0] || '');
|
||||||
|
const customer = String(arguments[1] || '');
|
||||||
|
function setValue(el, value) {
|
||||||
|
if (!el) return false;
|
||||||
|
el.focus();
|
||||||
|
el.value = value;
|
||||||
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||||
|
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function fillNearLabel(doc, label, value) {
|
||||||
|
if (!value) return false;
|
||||||
|
const nodes = Array.from(doc.querySelectorAll('td, th, span, label, div, em'));
|
||||||
|
for (const node of nodes) {
|
||||||
|
const text = (node.innerText || '').replace(/\\s+/g, '').trim();
|
||||||
|
if (text !== label && text !== label + ':' && text !== label + ':') continue;
|
||||||
|
const box = node.parentElement || node;
|
||||||
|
const input = box.querySelector('input[type="text"], input:not([type]), input[type="search"]');
|
||||||
|
if (setValue(input, value)) return true;
|
||||||
|
const next = node.nextElementSibling;
|
||||||
|
if (next && setValue(next.querySelector('input'), value)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function fillPlaceholder(doc, hint, value) {
|
||||||
|
if (!value) return false;
|
||||||
|
const inputs = Array.from(doc.querySelectorAll('input'));
|
||||||
|
const el = inputs.find((i) => (i.getAttribute('placeholder') || '').indexOf(hint) >= 0);
|
||||||
|
if (!el) return false;
|
||||||
|
setValue(el, value);
|
||||||
|
const btn = el.parentElement && el.parentElement.querySelector('a, button, span, i, em');
|
||||||
|
if (btn) btn.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function clickSearch(doc) {
|
||||||
|
const labels = ['搜索', '查询'];
|
||||||
|
const nodes = Array.from(doc.querySelectorAll('a, div, span, button, input'));
|
||||||
|
for (const label of labels) {
|
||||||
|
const hit = nodes.find((el) => {
|
||||||
|
const text = (el.textContent || el.value || '').trim();
|
||||||
|
return text === label && (!el.children || el.children.length === 0);
|
||||||
|
});
|
||||||
|
if (hit) { hit.click(); return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function walk(win) {
|
||||||
|
try {
|
||||||
|
fillPlaceholder(win.document, '合同(协议)全称', title)
|
||||||
|
|| fillPlaceholder(win.document, '合同标题', title)
|
||||||
|
|| fillNearLabel(win.document, '合同标题', title)
|
||||||
|
|| fillNearLabel(win.document, '合同(协议)全称', title);
|
||||||
|
fillPlaceholder(win.document, '客户编码', customer)
|
||||||
|
|| fillNearLabel(win.document, '客户编码', customer);
|
||||||
|
clickSearch(win.document);
|
||||||
|
} catch (e) {}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try { walk(win.frames[i]); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(window);
|
||||||
|
return true;
|
||||||
|
""",
|
||||||
|
title,
|
||||||
|
customer_code,
|
||||||
|
)
|
||||||
|
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
|
||||||
|
|
||||||
|
|
||||||
|
# 刮「我的合同」表格:编号、标题、客户、带 fdId 的链接。
|
||||||
|
def _scrape_my_contract_rows(browser: BrowserAdapter) -> list[dict]:
|
||||||
|
raw = browser.execute_script(
|
||||||
|
"""
|
||||||
|
function scrapeTable(doc) {
|
||||||
|
const rows = Array.from(doc.querySelectorAll('tr'));
|
||||||
|
let header = null;
|
||||||
|
const found = [];
|
||||||
|
for (const tr of rows) {
|
||||||
|
const cells = Array.from(tr.querySelectorAll('th, td')).map((c) =>
|
||||||
|
(c.innerText || '').replace(/\\s+/g, ' ').trim()
|
||||||
|
);
|
||||||
|
if (!header && cells.indexOf('合同编号') >= 0) {
|
||||||
|
header = {};
|
||||||
|
cells.forEach((h, i) => { if (h && header[h] === undefined) header[h] = i; });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!header) continue;
|
||||||
|
const no = (cells[header['合同编号']] || '').trim();
|
||||||
|
const title = (
|
||||||
|
cells[header['合同标题']]
|
||||||
|
|| cells[header['合同(协议)全称']]
|
||||||
|
|| cells[header['合同名称']]
|
||||||
|
|| cells[header['主题']]
|
||||||
|
|| cells[header['标题']]
|
||||||
|
|| ''
|
||||||
|
).trim();
|
||||||
|
const customer = (
|
||||||
|
cells[header['客户编码']] || cells[header['客户名称']] || cells[header['客商编码']] || ''
|
||||||
|
).trim();
|
||||||
|
const link = tr.querySelector('a[href*="fdId"], a[href*="fdid"]');
|
||||||
|
const box = tr.querySelector('input[name="List_Selected"]');
|
||||||
|
const href = link
|
||||||
|
? String(link.getAttribute('href') || '')
|
||||||
|
: (box ? String(box.value || '') : (tr.innerHTML || ''));
|
||||||
|
if (!no && !href) continue;
|
||||||
|
found.push({no: no, title: title, customer: customer, href: href});
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
function scrapeLinks(doc) {
|
||||||
|
const found = [];
|
||||||
|
const links = Array.from(doc.querySelectorAll('a[href*="fdId"], a[href*="fdid"]'));
|
||||||
|
for (const a of links) {
|
||||||
|
const href = String(a.getAttribute('href') || '');
|
||||||
|
const tr = a.closest('tr') || a.parentElement;
|
||||||
|
const blob = ((tr && tr.innerText) || a.innerText || '').replace(/\\s+/g, ' ').trim();
|
||||||
|
const hit = blob.match(/HT[0-9A-Za-z-]+/) || blob.match(/\\b\\d{10,}\\b/);
|
||||||
|
found.push({
|
||||||
|
no: hit ? hit[0] : '',
|
||||||
|
title: blob,
|
||||||
|
customer: blob,
|
||||||
|
href: href
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
function walk(win) {
|
||||||
|
let all = [];
|
||||||
|
try { all = all.concat(scrapeTable(win.document)); } catch (e) {}
|
||||||
|
try { all = all.concat(scrapeLinks(win.document)); } catch (e) {}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try { all = all.concat(walk(win.frames[i])); } catch (e) {}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
return walk(window);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
rows: list[dict] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"no": str(item.get("no") or "").strip(),
|
||||||
|
"title": str(item.get("title") or "").strip(),
|
||||||
|
"customer": str(item.get("customer") or "").strip(),
|
||||||
|
"href": str(item.get("href") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# 列表行的 href 里是否含有该 fdId。
|
||||||
|
def _row_has_fd_id(rows: list[dict], fd_id: str) -> bool:
|
||||||
|
token = (fd_id or "").strip()
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
return any(token in str(row.get("href") or "") for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
# 打开该 fdId 的详情页并读合同编号。文档不存在则返回空。
|
||||||
|
def _read_number_by_fd_id(browser: BrowserAdapter, fd_id: str) -> str:
|
||||||
|
doc_id = (fd_id or "").strip()
|
||||||
|
if not doc_id:
|
||||||
|
return ""
|
||||||
|
_open_submitted_view(browser, doc_id)
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
body = browser.read_body_text() or ""
|
||||||
|
if "该文档不存在" in body or "操作失败" in body:
|
||||||
|
print(f"S13 详情不存在 fdId={doc_id}", flush=True)
|
||||||
|
return ""
|
||||||
|
no = _read_contract_number_on_page(browser)
|
||||||
|
if no:
|
||||||
|
print("从合同详情读到合同编号:", no, flush=True)
|
||||||
|
return no
|
||||||
|
time.sleep(0.8)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 回「我的合同」按 fdId/标题+客户编码查号。匹配不到不取最新一条。
|
||||||
|
def _read_contract_number_from_my_list(
|
||||||
|
browser: BrowserAdapter,
|
||||||
|
ctx: ContractFlowContext,
|
||||||
|
timeout_seconds: int = 25,
|
||||||
|
fd_id: str = "",
|
||||||
|
) -> str:
|
||||||
|
title = _expected_title(ctx)
|
||||||
|
customer = (ctx.customer_code or "").strip()
|
||||||
|
print(
|
||||||
|
f"S13:回我的合同查询 fdId={fd_id or '-'} title={title} customer={customer}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
_open_my_contracts(browser, ctx)
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
searched = False
|
||||||
|
saw_row = False
|
||||||
|
while time.time() < deadline:
|
||||||
|
rows = _scrape_my_contract_rows(browser)
|
||||||
|
print(f"S13 列表行数={len(rows)}", flush=True)
|
||||||
|
for row in rows[:6]:
|
||||||
|
print(
|
||||||
|
"S13 row"
|
||||||
|
f" no={row.get('no')!r}"
|
||||||
|
f" title={str(row.get('title') or '')[:60]!r}"
|
||||||
|
f" customer={str(row.get('customer') or '')[:30]!r}"
|
||||||
|
f" href={str(row.get('href') or '')[:90]!r}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
no = _pick_contract_row(
|
||||||
|
rows,
|
||||||
|
fd_id=fd_id,
|
||||||
|
title=title,
|
||||||
|
customer_code=customer,
|
||||||
|
)
|
||||||
|
if no:
|
||||||
|
print("从我的合同读到合同编号:", no, flush=True)
|
||||||
|
return no
|
||||||
|
if fd_id and _row_has_fd_id(rows, fd_id):
|
||||||
|
saw_row = True
|
||||||
|
if not searched:
|
||||||
|
_search_my_contracts(browser, title, customer)
|
||||||
|
searched = True
|
||||||
|
time.sleep(1)
|
||||||
|
if saw_row and fd_id:
|
||||||
|
no = _read_number_by_fd_id(browser, fd_id)
|
||||||
|
if no:
|
||||||
|
return no
|
||||||
|
print("我的合同列表未匹配到合同编号", flush=True)
|
||||||
|
return ""
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
# 合同 S12:暂存后提交。成功后不要等详情页。
|
||||||
|
#
|
||||||
|
# 测试环境直接提交会落到「该文档不存在」,所以先点暂存再点提交。
|
||||||
|
# dry_run 不点提交,返回 False。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from accept import evidence_shot
|
||||||
|
|
||||||
|
from contract_common import (
|
||||||
|
EDIT_PAGE_URL,
|
||||||
|
BrowserAdapter,
|
||||||
|
ContractFlowContext,
|
||||||
|
_ACCEPT,
|
||||||
|
_click_toolbar_button,
|
||||||
|
_close_blocking_dialogs,
|
||||||
|
_current_url,
|
||||||
|
_lui_toolbar_button_exists,
|
||||||
|
_read_document_id,
|
||||||
|
_wait_lui_toolbar_button,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ERROR_MARKS = (
|
||||||
|
"不能为空",
|
||||||
|
"请填写",
|
||||||
|
"校验失败",
|
||||||
|
"提交失败",
|
||||||
|
"必填项未填",
|
||||||
|
"该文档不存在",
|
||||||
|
"操作失败",
|
||||||
|
)
|
||||||
|
_SUCCESS_MARKS = ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")
|
||||||
|
_CONFIRM_MARKS = ("确定提交", "是否提交", "确认提交", "是否确定", "确定要提交")
|
||||||
|
|
||||||
|
|
||||||
|
# 暂存后提交(S12)。dry_run 不点提交。成功后不要等详情页。
|
||||||
|
def submit_contract_for_approval(
|
||||||
|
browser: BrowserAdapter, ctx: ContractFlowContext, *, fd_id: str = ""
|
||||||
|
) -> bool:
|
||||||
|
if ctx.submit_mode == "dry_run":
|
||||||
|
evidence_shot(browser, "contract-dry-run-ready")
|
||||||
|
_ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True)
|
||||||
|
return False
|
||||||
|
_close_blocking_dialogs(browser)
|
||||||
|
fd_id = (fd_id or "").strip() or _read_document_id(browser)
|
||||||
|
print(f"S12 fdId={fd_id or '-'}", flush=True)
|
||||||
|
# 测试环境直接提交会落到「该文档不存在」:先暂存落库,再提交。合同号从详情 #_xform_docNumber 读。
|
||||||
|
try:
|
||||||
|
saved_click = _click_toolbar_button(browser, "暂存")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"暂存未点到:{exc}", flush=True)
|
||||||
|
saved_click = False
|
||||||
|
save_kind, save_text = _wait_submit_outcome(browser, timeout_seconds=15)
|
||||||
|
if save_kind == "error":
|
||||||
|
evidence_shot(browser, "contract-submitted", failed=True)
|
||||||
|
_ACCEPT.note(
|
||||||
|
"S12",
|
||||||
|
"提交",
|
||||||
|
{"submitted": "no", "error": save_text, "at": "暂存", "clickedSave": "yes" if saved_click else "no"},
|
||||||
|
ok=False,
|
||||||
|
raise_error=False,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if save_kind != "success":
|
||||||
|
print("暂存后仍在表单页,继续点提交。", flush=True)
|
||||||
|
fd_id = fd_id or _read_document_id(browser)
|
||||||
|
if not _lui_toolbar_button_exists(browser, "提交"):
|
||||||
|
if not fd_id:
|
||||||
|
evidence_shot(browser, "contract-submitted", failed=True)
|
||||||
|
_ACCEPT.note(
|
||||||
|
"S12",
|
||||||
|
"提交",
|
||||||
|
{"submitted": "no", "error": "暂存后离开表单且无 fdId,无法再点提交", "clicked": "no"},
|
||||||
|
ok=False,
|
||||||
|
raise_error=False,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
print(f"暂存后打开编辑页 fdId={fd_id}", flush=True)
|
||||||
|
browser.open_url(EDIT_PAGE_URL.format(fd_id=fd_id))
|
||||||
|
if not _wait_lui_toolbar_button(browser, "提交", timeout_seconds=12):
|
||||||
|
evidence_shot(browser, "contract-submitted", failed=True)
|
||||||
|
_ACCEPT.note(
|
||||||
|
"S12",
|
||||||
|
"提交",
|
||||||
|
{"submitted": "no", "error": "编辑页未见提交按钮 lui_widget_btn_txt", "clicked": "no", "fdId": fd_id},
|
||||||
|
ok=False,
|
||||||
|
raise_error=False,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
clicked = _click_toolbar_button(browser, "提交")
|
||||||
|
except Exception as exc:
|
||||||
|
evidence_shot(browser, "contract-submitted", failed=True)
|
||||||
|
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": str(exc)[:120], "clicked": "no"}, ok=False, raise_error=False)
|
||||||
|
return False
|
||||||
|
if not clicked:
|
||||||
|
evidence_shot(browser, "contract-submitted", failed=True)
|
||||||
|
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": "未点到提交按钮 lui_widget_btn_txt", "clicked": "no"}, ok=False, raise_error=False)
|
||||||
|
return False
|
||||||
|
evidence_shot(browser, "contract-after-submit-click")
|
||||||
|
kind, dialog = _wait_submit_outcome(browser, timeout_seconds=20, trust_body_success=False)
|
||||||
|
if kind == "error":
|
||||||
|
evidence_shot(browser, "contract-submitted", failed=True)
|
||||||
|
_ACCEPT.note(
|
||||||
|
"S12",
|
||||||
|
"提交",
|
||||||
|
{"submitted": "no", "clicked": "yes", "error": dialog},
|
||||||
|
ok=False,
|
||||||
|
raise_error=False,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
print(f"提交已点 kind={kind} dialog={dialog[:80]!r},S13 从详情读合同号。", flush=True)
|
||||||
|
_ACCEPT.note(
|
||||||
|
"S12",
|
||||||
|
"提交",
|
||||||
|
{
|
||||||
|
"submitted": "yes",
|
||||||
|
"clicked": "yes",
|
||||||
|
"via": "暂存后提交",
|
||||||
|
"signal": kind,
|
||||||
|
"dialog": dialog[:80] or "(none)",
|
||||||
|
},
|
||||||
|
ok=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# 从页面正文抽出提交失败原因。没有则返回空字符串。
|
||||||
|
def _submit_error_from_body(body: str) -> str:
|
||||||
|
text = body or ""
|
||||||
|
if "该文档不存在" in text:
|
||||||
|
return "该文档不存在"
|
||||||
|
if "操作失败" in text and "您的请求已提交" in text:
|
||||||
|
return "操作失败"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 把弹窗文案收成 error / success / confirm / none。
|
||||||
|
def _classify_dialog_text(text: str) -> str:
|
||||||
|
blob = text or ""
|
||||||
|
for mark in _ERROR_MARKS:
|
||||||
|
if mark in blob:
|
||||||
|
return "error"
|
||||||
|
for mark in _SUCCESS_MARKS:
|
||||||
|
if mark in blob:
|
||||||
|
return "success"
|
||||||
|
for mark in _CONFIRM_MARKS:
|
||||||
|
if mark in blob:
|
||||||
|
return "confirm"
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
# 等到提交弹窗给出成功或失败信号。超时返回 none。
|
||||||
|
def _wait_submit_outcome(
|
||||||
|
browser: BrowserAdapter,
|
||||||
|
timeout_seconds: float,
|
||||||
|
*,
|
||||||
|
trust_body_success: bool = True,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
last_dialog = ""
|
||||||
|
saw_confirm = False
|
||||||
|
while time.time() < deadline:
|
||||||
|
kind, text, confirm = _read_submit_signal(
|
||||||
|
browser,
|
||||||
|
trust_body_success=trust_body_success or saw_confirm,
|
||||||
|
)
|
||||||
|
if confirm:
|
||||||
|
saw_confirm = True
|
||||||
|
if text:
|
||||||
|
last_dialog = text
|
||||||
|
if kind == "error":
|
||||||
|
return "error", text
|
||||||
|
if kind == "success":
|
||||||
|
return "success", text
|
||||||
|
time.sleep(0.4)
|
||||||
|
kind, text, _confirm = _read_submit_signal(
|
||||||
|
browser,
|
||||||
|
trust_body_success=trust_body_success or saw_confirm,
|
||||||
|
)
|
||||||
|
if kind in ("error", "success"):
|
||||||
|
return kind, text
|
||||||
|
return "none", text or last_dialog
|
||||||
|
|
||||||
|
|
||||||
|
# 读一次原生 alert 和 LUI 弹窗,返回 kind、文案、是否点过确认。
|
||||||
|
def _read_submit_signal(
|
||||||
|
browser: BrowserAdapter,
|
||||||
|
*,
|
||||||
|
trust_body_success: bool = True,
|
||||||
|
) -> tuple[str, str, bool]:
|
||||||
|
alert_text = _accept_native_alert(browser)
|
||||||
|
if alert_text:
|
||||||
|
kind = _classify_dialog_text(alert_text)
|
||||||
|
if kind == "none":
|
||||||
|
kind = "confirm"
|
||||||
|
print(f"S12 native alert kind={kind} text={alert_text[:80]!r}", flush=True)
|
||||||
|
if kind == "error":
|
||||||
|
return "error", alert_text, False
|
||||||
|
if kind == "success":
|
||||||
|
return "success", alert_text, False
|
||||||
|
if kind == "confirm":
|
||||||
|
return _finish_submit_signal(browser, alert_text, True, trust_body_success)
|
||||||
|
snapshot = _handle_submit_dialogs(browser)
|
||||||
|
dialog_text = str(snapshot.get("text") or "").strip()
|
||||||
|
kind = _classify_dialog_text(dialog_text) if dialog_text else "none"
|
||||||
|
if kind == "none":
|
||||||
|
kind = str(snapshot.get("kind") or "none")
|
||||||
|
if kind == "error":
|
||||||
|
return "error", dialog_text or "弹窗报错", False
|
||||||
|
if kind == "success":
|
||||||
|
return "success", dialog_text or "ok", False
|
||||||
|
return _finish_submit_signal(
|
||||||
|
browser,
|
||||||
|
dialog_text,
|
||||||
|
kind == "confirm" or bool(snapshot.get("clicked")),
|
||||||
|
trust_body_success,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 弹窗之后再看正文和 URL,把提交结果收成 error / success / none。
|
||||||
|
def _finish_submit_signal(
|
||||||
|
browser: BrowserAdapter,
|
||||||
|
dialog_text: str,
|
||||||
|
saw_confirm: bool,
|
||||||
|
trust_body_success: bool,
|
||||||
|
) -> tuple[str, str, bool]:
|
||||||
|
body_error = _submit_error_from_body(browser.read_body_text() or "")
|
||||||
|
if body_error:
|
||||||
|
return "error", body_error, saw_confirm
|
||||||
|
if _submit_view_page(browser):
|
||||||
|
return "success", dialog_text or "view", saw_confirm
|
||||||
|
if (trust_body_success or saw_confirm) and _submit_success_text(browser):
|
||||||
|
return "success", dialog_text or "ok", saw_confirm
|
||||||
|
return "none", dialog_text, saw_confirm
|
||||||
|
|
||||||
|
|
||||||
|
# 有原生 alert 就点确定并返回文案。没有则返回空字符串。
|
||||||
|
def _accept_native_alert(browser: BrowserAdapter) -> str:
|
||||||
|
accept = getattr(browser, "accept_alert", None)
|
||||||
|
if not callable(accept):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return str(accept() or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 当前 URL 是否已经落到合同详情 view 页。
|
||||||
|
def _submit_view_page(browser: BrowserAdapter) -> bool:
|
||||||
|
url = _current_url(browser)
|
||||||
|
return "method=view" in url and "kmAgreementApply" in url
|
||||||
|
|
||||||
|
|
||||||
|
# 页面正文是否出现提交成功标记,且没有失败文案。
|
||||||
|
def _submit_success_text(browser: BrowserAdapter) -> bool:
|
||||||
|
body = browser.read_body_text() or ""
|
||||||
|
if _submit_error_from_body(body):
|
||||||
|
return False
|
||||||
|
return any(mark in body for mark in _SUCCESS_MARKS)
|
||||||
|
|
||||||
|
|
||||||
|
# 提交是否已经成功:成功文案或已到详情页,且正文没有失败。
|
||||||
|
def _submit_succeeded(browser: BrowserAdapter) -> bool:
|
||||||
|
if _submit_error_from_body(browser.read_body_text() or ""):
|
||||||
|
return False
|
||||||
|
return _submit_success_text(browser) or _submit_view_page(browser)
|
||||||
|
|
||||||
|
|
||||||
|
# 读当前弹窗/正文里的校验失败文案。没有则返回空字符串。
|
||||||
|
def _submit_validation_error(browser: BrowserAdapter) -> str:
|
||||||
|
kind, text, _confirm = _read_submit_signal(browser, trust_body_success=True)
|
||||||
|
if kind == "error":
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# 扫描 LUI 弹窗:报错原样返回,确认/成功则点确定。
|
||||||
|
def _handle_submit_dialogs(browser: BrowserAdapter) -> dict:
|
||||||
|
raw = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const errorMarks = ['不能为空','请填写','校验失败','提交失败','必填项未填','该文档不存在','操作失败'];
|
||||||
|
const successMarks = ['您的操作已成功','操作已成功','提交成功','流程已提交','成功提交'];
|
||||||
|
const confirmMarks = ['确定提交','是否提交','确认提交','是否确定','确定要提交'];
|
||||||
|
function classify(text) {
|
||||||
|
if (errorMarks.some((m) => text.indexOf(m) >= 0)) return 'error';
|
||||||
|
if (successMarks.some((m) => text.indexOf(m) >= 0)) return 'success';
|
||||||
|
if (confirmMarks.some((m) => text.indexOf(m) >= 0)) return 'confirm';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
function dialogText(doc) {
|
||||||
|
const roots = Array.from(doc.querySelectorAll(
|
||||||
|
'.lui_dialog, .lui_message, .lui_alert, .lui_dialog_content, .prompt_frame, .lui_dialog_common'
|
||||||
|
));
|
||||||
|
return roots.map((el) => (el.innerText || '').trim()).filter(Boolean).join('\\n');
|
||||||
|
}
|
||||||
|
function clickLabel(doc, labels) {
|
||||||
|
const nodes = Array.from(doc.querySelectorAll('div, a, button, span, input'));
|
||||||
|
for (const label of labels) {
|
||||||
|
const hit = nodes.find((el) => {
|
||||||
|
const t = (el.textContent || el.value || '').trim();
|
||||||
|
return t === label && (!el.children || el.children.length === 0);
|
||||||
|
});
|
||||||
|
if (hit) { hit.click(); return label; }
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function walk(win) {
|
||||||
|
let found = {kind: 'none', text: '', clicked: ''};
|
||||||
|
try {
|
||||||
|
const text = dialogText(win.document);
|
||||||
|
const kind = classify(text);
|
||||||
|
if (kind === 'error') return {kind: kind, text: text.slice(0, 200), clicked: ''};
|
||||||
|
if (kind === 'confirm') {
|
||||||
|
const clicked = clickLabel(win.document, ['确定', '确认', '是', 'OK']);
|
||||||
|
return {kind: kind, text: text.slice(0, 200), clicked: clicked};
|
||||||
|
}
|
||||||
|
if (kind === 'success') {
|
||||||
|
const clicked = clickLabel(win.document, ['确定', '关闭', '确认']);
|
||||||
|
return {kind: kind, text: text.slice(0, 200), clicked: clicked};
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
const clicked = clickLabel(win.document, ['确定', '确认']);
|
||||||
|
if (clicked) return {kind: 'confirm', text: text.slice(0, 200), clicked: clicked};
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
|
try {
|
||||||
|
const inner = walk(win.frames[i]);
|
||||||
|
if (inner.kind !== 'none' || inner.clicked) return inner;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return walk(window);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {"kind": "none", "text": "", "clicked": ""}
|
||||||
|
return {
|
||||||
|
"kind": str(raw.get("kind") or "none"),
|
||||||
|
"text": str(raw.get("text") or "").strip(),
|
||||||
|
"clicked": str(raw.get("clicked") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 等到提交确认弹窗消失或被点掉。
|
||||||
|
def _wait_lui_dialog_gone(browser: BrowserAdapter, timeout_seconds: float = 8) -> None:
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
while time.time() < deadline:
|
||||||
|
snapshot = _handle_submit_dialogs(browser)
|
||||||
|
kind = str(snapshot.get("kind") or "none")
|
||||||
|
text = str(snapshot.get("text") or "").strip()
|
||||||
|
if kind == "confirm":
|
||||||
|
return
|
||||||
|
if kind in ("none", "") and not text:
|
||||||
|
return
|
||||||
|
_close_blocking_dialogs(browser)
|
||||||
|
time.sleep(0.4)
|
||||||
|
_close_blocking_dialogs(browser)
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
# 合同 S11:挂合同正文。仅 YYC 传合并 PDF;提交前无文件则用占位 PDF。
|
||||||
|
#
|
||||||
|
# dry_run 且没有文件时跳过。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from accept import evidence_shot
|
||||||
|
from errors import NodeError
|
||||||
|
|
||||||
|
from contract_common import (
|
||||||
|
BrowserAdapter,
|
||||||
|
ContractFlowContext,
|
||||||
|
_ACCEPT,
|
||||||
|
_close_blocking_dialogs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 决定要挂的合同正文路径。提交且无文件时用占位 PDF。
|
||||||
|
def _resolve_contract_body_path(ctx: ContractFlowContext) -> str:
|
||||||
|
path = (ctx.contract_document_path or "").strip()
|
||||||
|
if not path and ctx.source_type == "YYC":
|
||||||
|
path = (ctx.merged_pdf_path or "").strip()
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
if ctx.submit_mode == "dry_run":
|
||||||
|
return ""
|
||||||
|
from pdf_utils import ensure_placeholder_contract_pdf
|
||||||
|
|
||||||
|
return ensure_placeholder_contract_pdf()
|
||||||
|
|
||||||
|
|
||||||
|
# 挂合同正文(S11)。dry_run 且没有文件则跳过。
|
||||||
|
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||||
|
path = _resolve_contract_body_path(ctx)
|
||||||
|
if not path:
|
||||||
|
evidence_shot(browser, "contract-pdf-upload-skipped")
|
||||||
|
_ACCEPT.note("S11", "上传正文", {"skipped": "dry_run_no_file"}, ok=True)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_upload_contract_body(browser, path)
|
||||||
|
except Exception as exc:
|
||||||
|
_ACCEPT.note("S11", "上传正文", {"attached": "no", "error": exc}, ok=False, raise_error=False)
|
||||||
|
raise
|
||||||
|
evidence_shot(browser, "contract-body-uploaded")
|
||||||
|
attached = _contract_body_attached(browser, path)
|
||||||
|
_ACCEPT.check(
|
||||||
|
"S11",
|
||||||
|
"上传正文",
|
||||||
|
[("attached", "yes" if attached else "no", "yes"), ("file", Path(path).name, Path(path).name)],
|
||||||
|
browser=browser,
|
||||||
|
screenshot_on_fail="contract-body-uploaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 打开合同正文区,点添加合同,把本地文件塞进 file input。
|
||||||
|
def _upload_contract_body(browser: BrowserAdapter, file_path: str) -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if not Path(file_path).is_file():
|
||||||
|
raise NodeError(f"合同正文文件不存在:{file_path}")
|
||||||
|
|
||||||
|
_close_blocking_dialogs(browser)
|
||||||
|
_reveal_contract_body_section(browser)
|
||||||
|
evidence_shot(browser, "contract-body-tab")
|
||||||
|
if _contract_body_attached(browser, file_path):
|
||||||
|
evidence_shot(browser, "contract-body-uploaded")
|
||||||
|
return
|
||||||
|
|
||||||
|
_click_add_contract_body(browser)
|
||||||
|
time.sleep(0.8)
|
||||||
|
try:
|
||||||
|
browser.click_native(["text=打开本地初稿", "text=本地初稿", "text=上传本地文件"])
|
||||||
|
time.sleep(0.8)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
browser.upload_file_first(
|
||||||
|
[
|
||||||
|
'input[name="_apply_filemainOnline"]',
|
||||||
|
'input[name="__landray_filemainOnline"]',
|
||||||
|
'input[name*="filemainOnline"]',
|
||||||
|
'input[name*="fdMainOnline"]',
|
||||||
|
'.lui_dialog input[type="file"]',
|
||||||
|
'input[type="file"]',
|
||||||
|
],
|
||||||
|
file_path,
|
||||||
|
)
|
||||||
|
deadline = time.time() + 12
|
||||||
|
while time.time() < deadline:
|
||||||
|
if _contract_body_attached(browser, file_path) or _on_body_editor_tab(browser):
|
||||||
|
evidence_shot(browser, "contract-body-uploaded")
|
||||||
|
return
|
||||||
|
time.sleep(0.6)
|
||||||
|
evidence_shot(browser, "contract-body-uploaded", failed=True)
|
||||||
|
raise NodeError("合同正文未挂上:页面仍是「+添加合同」,提交会被系统拒绝")
|
||||||
|
|
||||||
|
|
||||||
|
# 滚到或点开「合同正文」区域,露出「+添加合同」。
|
||||||
|
def _reveal_contract_body_section(browser: BrowserAdapter) -> None:
|
||||||
|
browser.execute_script(
|
||||||
|
"""
|
||||||
|
const labelOf = (el) => (el.textContent || '').replace(/\\s+/g, '').trim();
|
||||||
|
const addBtn = Array.from(document.querySelectorAll('a, span, div, td')).find((el) => {
|
||||||
|
const text = labelOf(el);
|
||||||
|
return text === '+添加合同' || text === '添加合同';
|
||||||
|
});
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.scrollIntoView({block: 'center'});
|
||||||
|
return {ok: true, via: 'add'};
|
||||||
|
}
|
||||||
|
const hits = Array.from(document.querySelectorAll('div, li, span, a')).filter((el) => labelOf(el) === '合同正文');
|
||||||
|
for (const el of hits) {
|
||||||
|
const group = (el.parentElement && el.parentElement.parentElement) || el;
|
||||||
|
if (labelOf(group).indexOf('签约方信息') >= 0) {
|
||||||
|
(el.closest('li') || el).click();
|
||||||
|
return {ok: true, via: 'tabbar'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {ok: false};
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
time.sleep(0.6)
|
||||||
|
|
||||||
|
|
||||||
|
# 点「+添加合同」打开本地初稿入口。
|
||||||
|
def _click_add_contract_body(browser: BrowserAdapter) -> None:
|
||||||
|
clicked = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const labelOf = (el) => (el.textContent || '').replace(/\\s+/g, '').trim();
|
||||||
|
const hit = Array.from(document.querySelectorAll('a, span, div, td, button')).find((el) => {
|
||||||
|
const text = labelOf(el);
|
||||||
|
return (text === '+添加合同' || text === '添加合同') && el.children.length <= 1;
|
||||||
|
});
|
||||||
|
if (!hit) return {ok: false};
|
||||||
|
hit.scrollIntoView({block: 'center'});
|
||||||
|
hit.click();
|
||||||
|
return {ok: true};
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if isinstance(clicked, dict) and clicked.get("ok"):
|
||||||
|
return
|
||||||
|
browser.click_native(["text=+添加合同", "text=添加合同"])
|
||||||
|
|
||||||
|
|
||||||
|
# 看合同正文是否已经挂上:文件名、office iframe 或 file input 有文件。
|
||||||
|
def _contract_body_attached(browser: BrowserAdapter, file_path: str) -> bool:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
stem = Path(file_path).name
|
||||||
|
result = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const wanted = String(arguments[0] || '');
|
||||||
|
const wantedStem = wanted.replace(/\\.[^.]+$/, '');
|
||||||
|
const fileInputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
||||||
|
if (fileInputs.some((el) => el.files && el.files.length > 0)) {
|
||||||
|
return {ok: true, via: 'input'};
|
||||||
|
}
|
||||||
|
const frames = Array.from(document.querySelectorAll('iframe, object, embed'));
|
||||||
|
const office = frames.find((el) => {
|
||||||
|
const hint = ((el.src || '') + ' ' + (el.id || '') + ' ' + (el.className || '')).toLowerCase();
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
return box.width > 80 && box.height > 80 && /office|ntko|wps|kingsoft|weboffice|filemain|online|pdf/.test(hint);
|
||||||
|
});
|
||||||
|
if (office) return {ok: true, via: 'iframe'};
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = Array.from(row.querySelectorAll('td, th'));
|
||||||
|
if (!cells.length) continue;
|
||||||
|
const head = (cells[0].innerText || '').replace(/\\s+/g, '').trim();
|
||||||
|
if (head !== '合同正文') continue;
|
||||||
|
const body = (row.innerText || '').replace(/\\s+/g, ' ').trim();
|
||||||
|
if (wanted && body.indexOf(wanted) >= 0) return {ok: true, via: 'name'};
|
||||||
|
if (wantedStem && body.indexOf(wantedStem) >= 0) return {ok: true, via: 'stem'};
|
||||||
|
if (/\\.(pdf|doc|docx|wps)/i.test(body)) return {ok: true, via: 'ext'};
|
||||||
|
if (body.indexOf('添加合同') >= 0 && body.indexOf('打开本地初稿') < 0) return {ok: false, via: 'empty'};
|
||||||
|
}
|
||||||
|
const page = (document.body.innerText || '');
|
||||||
|
if (wanted && page.indexOf(wanted) >= 0) return {ok: true, via: 'page'};
|
||||||
|
return {ok: false, via: 'missing'};
|
||||||
|
""",
|
||||||
|
stem,
|
||||||
|
)
|
||||||
|
return isinstance(result, dict) and bool(result.get("ok"))
|
||||||
|
|
||||||
|
|
||||||
|
# 底部「合同正文」在线编辑页:文件进编辑器后不一定回显文件名。
|
||||||
|
def _on_body_editor_tab(browser: BrowserAdapter) -> bool:
|
||||||
|
result = browser.execute_script(
|
||||||
|
"""
|
||||||
|
const page = (document.body.innerText || '').replace(/\\s+/g, '');
|
||||||
|
return page.indexOf('打开本地初稿') >= 0 && page.indexOf('添加合同') < 0;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return result is True
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
"""Oracle 11.2 thick 连接。环境变量与 rpa_oms 相同。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_client_ready = False
|
|
||||||
_PROJECT_ROOT = Path(__file__).resolve().parent
|
|
||||||
_OMS_ENV = _PROJECT_ROOT.parent / "rpa_oms" / ".env"
|
|
||||||
|
|
||||||
|
|
||||||
class RepoUnavailable(Exception):
|
|
||||||
def __init__(self, message: str = "数据库不可用"):
|
|
||||||
self.message = message
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
def load_runtime_env(paths: list[Path] | None = None) -> None:
|
|
||||||
"""先读合同 .env,空缺再补 rpa_oms/.env。不覆盖已有非空环境变量。"""
|
|
||||||
env_paths = paths if paths is not None else [_PROJECT_ROOT / ".env", _OMS_ENV]
|
|
||||||
for path in env_paths:
|
|
||||||
_fill_env(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _fill_env(path: Path) -> None:
|
|
||||||
if not path.is_file():
|
|
||||||
return
|
|
||||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
line = raw.strip()
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip()
|
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
||||||
value = value[1:-1]
|
|
||||||
if not key or not value:
|
|
||||||
continue
|
|
||||||
current = os.environ.get(key, "")
|
|
||||||
if not current:
|
|
||||||
os.environ[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
def oracle_dsn() -> tuple[str, str, str]:
|
|
||||||
user = os.getenv("RPA_ORACLE_USER", "")
|
|
||||||
password = os.getenv("RPA_ORACLE_PASSWORD", "")
|
|
||||||
host = os.getenv("RPA_ORACLE_HOST", "")
|
|
||||||
if not user or not password or not host:
|
|
||||||
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
|
|
||||||
port = os.getenv("RPA_ORACLE_PORT", "1521")
|
|
||||||
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
|
|
||||||
return user, password, f"{host}:{port}/{service}"
|
|
||||||
|
|
||||||
|
|
||||||
def connect_from_env():
|
|
||||||
user, password, dsn = oracle_dsn()
|
|
||||||
_ensure_client()
|
|
||||||
import oracledb
|
|
||||||
|
|
||||||
return oracledb.connect(user=user, password=password, dsn=dsn)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_client() -> None:
|
|
||||||
global _client_ready
|
|
||||||
if _client_ready:
|
|
||||||
return
|
|
||||||
import oracledb
|
|
||||||
|
|
||||||
if os.getenv("RPA_ORACLE_THICK", "1") == "1":
|
|
||||||
lib_dir = os.getenv("RPA_ORACLE_CLIENT_LIB", "")
|
|
||||||
kwargs = {"lib_dir": lib_dir} if lib_dir else {}
|
|
||||||
oracledb.init_oracle_client(**kwargs)
|
|
||||||
_client_ready = True
|
|
||||||
|
|
@ -1,36 +1,41 @@
|
||||||
"""Page-flow errors. Codes stay for logs; they no longer map to a state-center fail callback."""
|
# 页面流错误。错误码留在日志里,不再回调旧状态中心。
|
||||||
|
|
||||||
|
|
||||||
|
# 页面元素找不到或页面改版。不重试,交给运维。
|
||||||
class NodeError(RuntimeError):
|
class NodeError(RuntimeError):
|
||||||
error_code = "UI_ELEMENT_NOT_FOUND"
|
error_code = "UI_ELEMENT_NOT_FOUND"
|
||||||
retryable = False
|
retryable = False
|
||||||
route_role = "OPS"
|
route_role = "OPS"
|
||||||
|
|
||||||
|
|
||||||
|
# 合同系统登录失败。可以重试。
|
||||||
class LoginFailed(NodeError):
|
class LoginFailed(NodeError):
|
||||||
error_code = "SYS_LOGIN_FAIL"
|
error_code = "SYS_LOGIN_FAIL"
|
||||||
retryable = True
|
retryable = True
|
||||||
route_role = "OPS"
|
route_role = "OPS"
|
||||||
|
|
||||||
|
|
||||||
|
# 供应商在合同系统里不能起草。不重试,交给采购。
|
||||||
class SupplierContractDisabled(NodeError):
|
class SupplierContractDisabled(NodeError):
|
||||||
error_code = "BIZ_CONTRACT_DISABLED"
|
error_code = "BIZ_CONTRACT_DISABLED"
|
||||||
retryable = False
|
retryable = False
|
||||||
route_role = "PURCHASER"
|
route_role = "PURCHASER"
|
||||||
|
|
||||||
|
|
||||||
|
# YYC 多份平台 PDF 合并不了。不重试,交给运维。
|
||||||
class PdfMergeUnsupported(NodeError):
|
class PdfMergeUnsupported(NodeError):
|
||||||
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
|
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
|
||||||
retryable = False
|
retryable = False
|
||||||
route_role = "OPS"
|
route_role = "OPS"
|
||||||
|
|
||||||
|
|
||||||
|
# 页面流里没归类的异常。可以重试。
|
||||||
class UnknownRpaError(NodeError):
|
class UnknownRpaError(NodeError):
|
||||||
error_code = "RPA_UNKNOWN_ERROR"
|
error_code = "RPA_UNKNOWN_ERROR"
|
||||||
retryable = True
|
retryable = True
|
||||||
route_role = "OPS"
|
route_role = "OPS"
|
||||||
|
|
||||||
|
|
||||||
|
# 客商未尽调。不提交合同,不重试,交给采购。
|
||||||
class DiligenceError(NodeError):
|
class DiligenceError(NodeError):
|
||||||
error_code = "BIZ_CUSTOMER_NOT_DILIGENCE"
|
error_code = "BIZ_CUSTOMER_NOT_DILIGENCE"
|
||||||
retryable = False
|
retryable = False
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""合同班合单:可录判定、按供应商分组、草稿映射。"""
|
# 合同班合单:可录判定、按供应商+货主+三个日期分组、草稿映射。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
@ -7,25 +8,48 @@ from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
from contract_draft import ContractDraft
|
from contract_draft import ContractDraft
|
||||||
from contract_flow_steps import ContractGoodsLine
|
from contract_flow_steps import ContractGoodsLine
|
||||||
|
from persist import LOOKUP_ONLY_RESULT
|
||||||
from repo import PurchaseContractRow
|
from repo import PurchaseContractRow
|
||||||
|
|
||||||
|
|
||||||
|
# 判断是否只录协议行:contract_entry_status=0 且已有合同号。
|
||||||
|
#
|
||||||
|
# 合同班跳过这些行,留给返利班。
|
||||||
|
def is_protocol_rerun(row: PurchaseContractRow) -> bool:
|
||||||
|
if row.contract_entry_status is not None and int(row.contract_entry_status) == 0:
|
||||||
|
return bool((row.contract_no or "").strip())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 判断是否可领:process_status=0 且不是协议重录行。
|
||||||
|
#
|
||||||
|
# 过期的 process_status=3 由仓库租约逻辑另判,本函数只认新鲜待领。
|
||||||
def is_claimable(row: PurchaseContractRow) -> bool:
|
def is_claimable(row: PurchaseContractRow) -> bool:
|
||||||
if int(row.process_status or 0) != 0:
|
if int(row.process_status or 0) != 0:
|
||||||
return False
|
return False
|
||||||
if row.contract_entry_status is not None and int(row.contract_entry_status) == 0:
|
return not is_protocol_rerun(row)
|
||||||
if (row.contract_no or "").strip():
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
|
# 合单键:供应商、货主、签订日、开始日、结束日。任一不同则各自成组。
|
||||||
|
def _group_key(row: PurchaseContractRow) -> tuple:
|
||||||
|
return (
|
||||||
|
row.supplier_id,
|
||||||
|
row.org_code,
|
||||||
|
row.contract_sign_date,
|
||||||
|
row.contract_start_date,
|
||||||
|
row.contract_end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 按合单键分组,组内按 fid 升序,头字段取该组第一行。
|
||||||
def group_by_supplier(rows: list[PurchaseContractRow]) -> list[list[PurchaseContractRow]]:
|
def group_by_supplier(rows: list[PurchaseContractRow]) -> list[list[PurchaseContractRow]]:
|
||||||
groups: OrderedDict[str, list[PurchaseContractRow]] = OrderedDict()
|
groups: OrderedDict[tuple, list[PurchaseContractRow]] = OrderedDict()
|
||||||
for row in sorted(rows, key=lambda item: item.fid):
|
for row in sorted(rows, key=lambda item: item.fid):
|
||||||
groups.setdefault(row.supplier_id, []).append(row)
|
groups.setdefault(_group_key(row), []).append(row)
|
||||||
return list(groups.values())
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
# 检查合单是否缺供应商或货品 MDM 码。缺则返回原因,齐则返回 None。
|
||||||
def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
|
def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
|
||||||
if not rows:
|
if not rows:
|
||||||
return "缺少供应商MDM码"
|
return "缺少供应商MDM码"
|
||||||
|
|
@ -37,11 +61,13 @@ def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 把金额收成四位小数字符串,四舍五入。
|
||||||
def money4(value) -> str:
|
def money4(value) -> str:
|
||||||
amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||||
return f"{amount:.4f}"
|
return f"{amount:.4f}"
|
||||||
|
|
||||||
|
|
||||||
|
# 把日期收成 YYYY-MM-DD。空值返回空字符串。
|
||||||
def iso_date(value) -> str:
|
def iso_date(value) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -53,6 +79,10 @@ def iso_date(value) -> str:
|
||||||
|
|
||||||
|
|
||||||
def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
|
def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
|
||||||
|
"""把同一供应商一组表 3 行收成页面草稿。头字段取第一行。
|
||||||
|
|
||||||
|
结果里已有「只查号」时,下一班只查已有合同,不再起草。
|
||||||
|
"""
|
||||||
first = rows[0]
|
first = rows[0]
|
||||||
sign = iso_date(first.contract_sign_date)
|
sign = iso_date(first.contract_sign_date)
|
||||||
total = sum(
|
total = sum(
|
||||||
|
|
@ -89,4 +119,5 @@ def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
|
||||||
payment_method="账期支付",
|
payment_method="账期支付",
|
||||||
payment_days=str(first.payment_term),
|
payment_days=str(first.payment_term),
|
||||||
goods_lines=goods,
|
goods_lines=goods,
|
||||||
|
lookup_only=any(LOOKUP_ONLY_RESULT in (row.process_result or "") for row in rows),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"""合同班次:领取表 3 → 按供应商合单 → runner 录合同 → 写表 4。"""
|
# 合同班次:领取表 3 → 按供应商、货主和三个日期合单 → runner 录合同 → 写表 4。
|
||||||
|
#
|
||||||
|
# 崩在 process_status=3:超过租约可再领,未过期需改回 0。
|
||||||
|
# 未捕获异常原因列只写 PAGE_FAILED。原文进审计表,会话结束后另写。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
|
@ -8,10 +14,14 @@ from contract_draft import ContractDraft
|
||||||
from errors import DiligenceError, NodeError
|
from errors import DiligenceError, NodeError
|
||||||
from group import draft_from_group, group_by_supplier, missing_mdm_reason
|
from group import draft_from_group, group_by_supplier, missing_mdm_reason
|
||||||
from persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome, build_save
|
from persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome, build_save
|
||||||
|
from rpa_oracle import ShiftError
|
||||||
|
|
||||||
|
PAGE_FAILED = "PAGE_FAILED"
|
||||||
Runner = Callable[[ContractDraft], ContractOutcome]
|
Runner = Callable[[ContractDraft], ContractOutcome]
|
||||||
|
logger = logging.getLogger("rpa_contract")
|
||||||
|
|
||||||
|
|
||||||
|
# 一个合同班次的领取、合单和成败计数。
|
||||||
@dataclass
|
@dataclass
|
||||||
class JobSummary:
|
class JobSummary:
|
||||||
claimed: int = 0
|
claimed: int = 0
|
||||||
|
|
@ -20,33 +30,62 @@ class JobSummary:
|
||||||
failed: int = 0
|
failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _session(repo):
|
||||||
|
"""有 session 就借一条,没有就空上下文。页面调用必须在这个块外面。"""
|
||||||
|
session = getattr(repo, "session", None)
|
||||||
|
if session is None:
|
||||||
|
return nullcontext()
|
||||||
|
return session()
|
||||||
|
|
||||||
|
|
||||||
def run_batch(repo, *, runner: Runner, limit: int | None = None) -> JobSummary:
|
def run_batch(repo, *, runner: Runner, limit: int | None = None) -> JobSummary:
|
||||||
|
"""领取待录合同行,按供应商合单、调 runner、落表。
|
||||||
|
|
||||||
|
session 只包领取和每一组的落表。页面在连接归还之后跑。一组落完再处理下一组。
|
||||||
|
limit 交给领取层。
|
||||||
|
"""
|
||||||
|
audits: list[ShiftError] = []
|
||||||
|
with _session(repo):
|
||||||
claimed = repo.claim_pending(limit=limit)
|
claimed = repo.claim_pending(limit=limit)
|
||||||
summary = JobSummary(claimed=len(claimed), groups=0)
|
summary = JobSummary(claimed=len(claimed), groups=0)
|
||||||
for rows in group_by_supplier(claimed):
|
for rows in group_by_supplier(claimed):
|
||||||
summary.groups += 1
|
summary.groups += 1
|
||||||
outcome = _run_group(rows, runner)
|
outcome = _run_group(rows, runner)
|
||||||
|
with _session(repo):
|
||||||
repo.save_group(build_save(rows, outcome))
|
repo.save_group(build_save(rows, outcome))
|
||||||
|
if outcome.error_text:
|
||||||
|
supplier = rows[0].supplier_id if rows else ""
|
||||||
|
audits.append(ShiftError("合同", str(supplier), PAGE_FAILED, outcome.error_text))
|
||||||
if outcome.kind == KIND_OK and (outcome.contract_no or "").strip():
|
if outcome.kind == KIND_OK and (outcome.contract_no or "").strip():
|
||||||
summary.ok += 1
|
summary.ok += 1
|
||||||
else:
|
else:
|
||||||
summary.failed += 1
|
summary.failed += 1
|
||||||
|
repo.save_shift_errors(audits)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# 录一组同一供应商的行。缺 MDM 不调页面;未捕获异常写 PAGE_FAILED。
|
||||||
def _run_group(rows, runner: Runner) -> ContractOutcome:
|
def _run_group(rows, runner: Runner) -> ContractOutcome:
|
||||||
reason = missing_mdm_reason(rows)
|
reason = missing_mdm_reason(rows)
|
||||||
if reason:
|
if reason:
|
||||||
return ContractOutcome(KIND_FAIL, None, reason)
|
return ContractOutcome(KIND_FAIL, None, reason)
|
||||||
try:
|
try:
|
||||||
outcome = runner(draft_from_group(rows))
|
outcome = runner(draft_from_group(rows))
|
||||||
except Exception as exc:
|
except DiligenceError:
|
||||||
|
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
|
||||||
|
except NodeError as exc:
|
||||||
return ContractOutcome(KIND_FAIL, None, str(exc))
|
return ContractOutcome(KIND_FAIL, None, str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("合同录入未捕获异常 supplier=%s", rows[0].supplier_id if rows else "")
|
||||||
|
return ContractOutcome(KIND_FAIL, None, PAGE_FAILED, _text(exc))
|
||||||
if outcome.kind == KIND_OK and not (outcome.contract_no or "").strip():
|
if outcome.kind == KIND_OK and not (outcome.contract_no or "").strip():
|
||||||
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
|
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
|
||||||
return outcome
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
# 把页面 run() 的合同号或异常收成 ContractOutcome。
|
||||||
|
#
|
||||||
|
# 尽调失败写客户未尽调;NodeError 写业务文案;其它异常写 PAGE_FAILED,原文放 error_text。
|
||||||
def outcome_from_run(run: Callable[[], str]) -> ContractOutcome:
|
def outcome_from_run(run: Callable[[], str]) -> ContractOutcome:
|
||||||
try:
|
try:
|
||||||
number = (run() or "").strip()
|
number = (run() or "").strip()
|
||||||
|
|
@ -55,7 +94,14 @@ def outcome_from_run(run: Callable[[], str]) -> ContractOutcome:
|
||||||
except NodeError as exc:
|
except NodeError as exc:
|
||||||
return ContractOutcome(KIND_FAIL, None, str(exc))
|
return ContractOutcome(KIND_FAIL, None, str(exc))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ContractOutcome(KIND_FAIL, None, str(exc))
|
logger.exception("合同页面未捕获异常")
|
||||||
|
return ContractOutcome(KIND_FAIL, None, PAGE_FAILED, _text(exc))
|
||||||
if not number:
|
if not number:
|
||||||
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
|
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
|
||||||
return ContractOutcome(KIND_OK, number, None)
|
return ContractOutcome(KIND_OK, number, None)
|
||||||
|
|
||||||
|
|
||||||
|
# 取出异常原文。空串不当审计正文。
|
||||||
|
def _text(exc: BaseException) -> str | None:
|
||||||
|
text = str(exc).strip()
|
||||||
|
return text or None
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# 合同起草命令行入口。真正的入参组装和页面流在 contract_create_node。
|
||||||
|
|
||||||
from contract_create_node import main
|
from contract_create_node import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""合同 PDF 合并、提交占位正文。"""
|
# 合同 PDF 合并、提交占位正文。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -6,8 +7,8 @@ from pathlib import Path
|
||||||
_PLACEHOLDER_PDF = Path(__file__).resolve().parent / "examples" / "placeholder_contract.pdf"
|
_PLACEHOLDER_PDF = Path(__file__).resolve().parent / "examples" / "placeholder_contract.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
# 提交必须挂合同正文;没有平台文件时用这一份占位 PDF。
|
||||||
def ensure_placeholder_contract_pdf() -> str:
|
def ensure_placeholder_contract_pdf() -> str:
|
||||||
"""提交必须挂合同正文;没有平台文件时用这一份占位 PDF。"""
|
|
||||||
path = _PLACEHOLDER_PDF
|
path = _PLACEHOLDER_PDF
|
||||||
if path.is_file() and path.stat().st_size > 64:
|
if path.is_file() and path.stat().st_size > 64:
|
||||||
return str(path)
|
return str(path)
|
||||||
|
|
@ -21,11 +22,8 @@ def ensure_placeholder_contract_pdf() -> str:
|
||||||
return str(path)
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
# 合成一份要上传的合同正文。同供应商同日只传这一份。单文件直接返回路径。
|
||||||
def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str:
|
def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str:
|
||||||
"""Return one PDF path, merging multiple source PDFs when needed.
|
|
||||||
|
|
||||||
同供应商同日只上传一份 PDF。单文件直接返回路径。
|
|
||||||
"""
|
|
||||||
if not source_paths:
|
if not source_paths:
|
||||||
raise ValueError("YYC contract creation requires at least one platform PDF")
|
raise ValueError("YYC contract creation requires at least one platform PDF")
|
||||||
if len(source_paths) == 1:
|
if len(source_paths) == 1:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""合同班结果:ContractOutcome → 表 4 行 + 表 3 状态。"""
|
# 合同班结果:ContractOutcome → 表 4 行 + 表 3 状态。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -8,18 +9,26 @@ from repo import PurchaseContractRow
|
||||||
KIND_OK = "ok"
|
KIND_OK = "ok"
|
||||||
KIND_DILIGENCE = "diligence"
|
KIND_DILIGENCE = "diligence"
|
||||||
KIND_FAIL = "fail"
|
KIND_FAIL = "fail"
|
||||||
STAMP = "合同班"
|
KIND_HOLD = "hold"
|
||||||
|
# 提交后没读到号。下一班看到这个结果只查号,不再起草。
|
||||||
|
LOOKUP_ONLY_RESULT = "只查号"
|
||||||
|
USER_NAME = "系统管理员"
|
||||||
|
TABLE4_PENDING = 0
|
||||||
|
TABLE4_SKIP = 2
|
||||||
PROCESS_RESULT_MAX = 1000
|
PROCESS_RESULT_MAX = 1000
|
||||||
FAIL_REASON_MAX = 500
|
FAIL_REASON_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
# 录合同结果。error_text 只给审计表,不进原因列。
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ContractOutcome:
|
class ContractOutcome:
|
||||||
kind: str
|
kind: str
|
||||||
contract_no: str | None
|
contract_no: str | None
|
||||||
reason: str | None
|
reason: str | None
|
||||||
|
error_text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# 一组合同要写回的内容:表 3 的 fid 和状态,以及表 4 起草行。
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GroupSave:
|
class GroupSave:
|
||||||
fids: list[int]
|
fids: list[int]
|
||||||
|
|
@ -28,6 +37,7 @@ class GroupSave:
|
||||||
table4_rows: list[dict]
|
table4_rows: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
# 原因超长时截断并加省略号,避免写爆表 3 / 表 4 的原因列。
|
||||||
def clip(text: str, max_len: int) -> str:
|
def clip(text: str, max_len: int) -> str:
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if len(text) <= max_len:
|
if len(text) <= max_len:
|
||||||
|
|
@ -36,20 +46,35 @@ def clip(text: str, max_len: int) -> str:
|
||||||
|
|
||||||
|
|
||||||
def build_save(rows: list[PurchaseContractRow], outcome: ContractOutcome) -> GroupSave:
|
def build_save(rows: list[PurchaseContractRow], outcome: ContractOutcome) -> GroupSave:
|
||||||
|
"""按录合同结果生成落库内容。
|
||||||
|
|
||||||
|
读到合同号:表 3 成功,表 4 起草行待返利领取。
|
||||||
|
未尽调或失败:表 3 失败,表 4 记失败并不再领。
|
||||||
|
查重多条或提交后没读到号:表 3 停在 3,不写表 4。同一 oms_id 只留一行。
|
||||||
|
"""
|
||||||
kind = outcome.kind
|
kind = outcome.kind
|
||||||
number = (outcome.contract_no or "").strip() or None
|
number = (outcome.contract_no or "").strip() or None
|
||||||
|
if kind == KIND_HOLD:
|
||||||
|
return GroupSave(
|
||||||
|
fids=[row.fid for row in rows],
|
||||||
|
process_status=3,
|
||||||
|
process_result=clip(outcome.reason or LOOKUP_ONLY_RESULT, PROCESS_RESULT_MAX),
|
||||||
|
table4_rows=[],
|
||||||
|
)
|
||||||
if kind == KIND_OK and number:
|
if kind == KIND_OK and number:
|
||||||
process_status = 1
|
process_status = 1
|
||||||
process_result = "合同已提交"
|
process_result = "合同已提交"
|
||||||
contract_status = 1
|
contract_status = 1
|
||||||
fail_reason = None
|
fail_reason = None
|
||||||
contract_no = number
|
contract_no = number
|
||||||
|
table4_process_status = TABLE4_PENDING
|
||||||
elif kind == KIND_DILIGENCE:
|
elif kind == KIND_DILIGENCE:
|
||||||
process_status = 2
|
process_status = 2
|
||||||
process_result = clip("客户未尽调", PROCESS_RESULT_MAX)
|
process_result = clip("客户未尽调", PROCESS_RESULT_MAX)
|
||||||
contract_status = 3
|
contract_status = 3
|
||||||
fail_reason = "客户未尽调"
|
fail_reason = "客户未尽调"
|
||||||
contract_no = None
|
contract_no = None
|
||||||
|
table4_process_status = TABLE4_SKIP
|
||||||
else:
|
else:
|
||||||
reason = outcome.reason or "合同录入失败"
|
reason = outcome.reason or "合同录入失败"
|
||||||
if kind == KIND_OK and not number:
|
if kind == KIND_OK and not number:
|
||||||
|
|
@ -59,6 +84,7 @@ def build_save(rows: list[PurchaseContractRow], outcome: ContractOutcome) -> Gro
|
||||||
contract_status = 3
|
contract_status = 3
|
||||||
fail_reason = clip(reason, FAIL_REASON_MAX)
|
fail_reason = clip(reason, FAIL_REASON_MAX)
|
||||||
contract_no = None
|
contract_no = None
|
||||||
|
table4_process_status = TABLE4_SKIP
|
||||||
|
|
||||||
seen: dict[int, PurchaseContractRow] = {}
|
seen: dict[int, PurchaseContractRow] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|
@ -75,11 +101,12 @@ def build_save(rows: list[PurchaseContractRow], outcome: ContractOutcome) -> Gro
|
||||||
"agreement_id": None,
|
"agreement_id": None,
|
||||||
"agreement_dtl_id": None,
|
"agreement_dtl_id": None,
|
||||||
"push_status": 0,
|
"push_status": 0,
|
||||||
|
"process_status": table4_process_status,
|
||||||
"org_code": row.org_code,
|
"org_code": row.org_code,
|
||||||
"create_user_id": 0,
|
"create_user_id": 0,
|
||||||
"create_user_name": STAMP,
|
"create_user_name": USER_NAME,
|
||||||
"update_user_id": 0,
|
"update_user_id": 0,
|
||||||
"update_user_name": STAMP,
|
"update_user_name": USER_NAME,
|
||||||
}
|
}
|
||||||
for row in seen.values()
|
for row in seen.values()
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ dev = [
|
||||||
package = false
|
package = false
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = [".", ".."]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-q"
|
addopts = "-q"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,98 @@
|
||||||
"""表 3 领取、表 4 写入。"""
|
# 表 3 领取、表 4 写入。
|
||||||
|
#
|
||||||
|
# 从表 3 领取 process_status=0 的行改成 3,合单成功后写表 4、表 3 改成 1。
|
||||||
|
# 进程中途退出时行停在 3,超过租约(process_time 30 分钟)才能再领。
|
||||||
|
# 跳过协议重录行。不推接口 4。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from db import RepoUnavailable, connect_from_env, oracle_dsn
|
from rpa_oracle import RepoUnavailable, job_pool, update_by_fid_chunks
|
||||||
|
from rpa_oracle.audit import ShiftError, save_shift_errors as write_shift_errors
|
||||||
|
|
||||||
|
LEASE_MINUTES = 30
|
||||||
|
|
||||||
|
_SELECT_BODY = """
|
||||||
|
SELECT
|
||||||
|
fid, oms_id, supplier_id, supplier_name,
|
||||||
|
contract_sign_date, contract_start_date, contract_end_date,
|
||||||
|
payment_term, goods_id, goods_name, reg_no, spec, unit,
|
||||||
|
qty, unit_price, purchaser_id, purchaser_name, planner_id, planner_name,
|
||||||
|
contract_entry_status, contract_no, process_status, org_code,
|
||||||
|
goods_mdm_code, supplier_mdm_code,
|
||||||
|
NVL(process_result, '') AS process_result
|
||||||
|
FROM rpa_purchase_contract_data
|
||||||
|
WHERE (
|
||||||
|
process_status = 0
|
||||||
|
OR (process_status = 3 AND process_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE'))
|
||||||
|
)
|
||||||
|
AND NOT (NVL(contract_entry_status, -1) = 0
|
||||||
|
AND contract_no IS NOT NULL
|
||||||
|
AND LENGTH(TRIM(contract_no)) > 0)
|
||||||
|
ORDER BY fid
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_PENDING = _SELECT_BODY + "\nFOR UPDATE SKIP LOCKED"
|
||||||
|
|
||||||
|
_SELECT_PENDING_LIMIT = f"""
|
||||||
|
SELECT * FROM (
|
||||||
|
{_SELECT_BODY}
|
||||||
|
) WHERE ROWNUM <= :limit
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CLAIM_SQL = """
|
||||||
|
UPDATE rpa_purchase_contract_data
|
||||||
|
SET process_status = 3,
|
||||||
|
process_time = SYSDATE,
|
||||||
|
update_time = SYSDATE
|
||||||
|
WHERE fid IN ({placeholders})
|
||||||
|
"""
|
||||||
|
|
||||||
|
_INSERT_TABLE4_SQL = """
|
||||||
|
INSERT INTO rpa_contract_agreement_result (
|
||||||
|
oms_id, action_code, contract_status, contract_fail_reason, contract_no,
|
||||||
|
agreement_status, agreement_fail_reason, agreement_id, agreement_dtl_id,
|
||||||
|
push_status, process_status, create_time, update_time,
|
||||||
|
create_user_id, create_user_name, update_user_id, update_user_name, org_code
|
||||||
|
) VALUES (
|
||||||
|
:oms_id, :action_code, :contract_status, :contract_fail_reason, :contract_no,
|
||||||
|
:agreement_status, :agreement_fail_reason, :agreement_id, :agreement_dtl_id,
|
||||||
|
0, :process_status, SYSTIMESTAMP, SYSTIMESTAMP,
|
||||||
|
0, '系统管理员', 0, '系统管理员', :org_code
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_COMPLETE_SQL = """
|
||||||
|
UPDATE rpa_purchase_contract_data
|
||||||
|
SET process_status = :process_status,
|
||||||
|
process_result = :process_result,
|
||||||
|
process_time = SYSTIMESTAMP,
|
||||||
|
update_time = SYSTIMESTAMP
|
||||||
|
WHERE fid = :fid AND process_status = 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
_TABLE4_BIND_KEYS = (
|
||||||
|
"oms_id",
|
||||||
|
"action_code",
|
||||||
|
"contract_status",
|
||||||
|
"contract_fail_reason",
|
||||||
|
"contract_no",
|
||||||
|
"agreement_status",
|
||||||
|
"agreement_fail_reason",
|
||||||
|
"agreement_id",
|
||||||
|
"agreement_dtl_id",
|
||||||
|
"process_status",
|
||||||
|
"org_code",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 表 3 一行待录或处理中的采购合同。
|
||||||
@dataclass
|
@dataclass
|
||||||
class PurchaseContractRow:
|
class PurchaseContractRow:
|
||||||
fid: int
|
fid: int
|
||||||
|
|
@ -38,107 +121,36 @@ class PurchaseContractRow:
|
||||||
goods_mdm_code: str | None
|
goods_mdm_code: str | None
|
||||||
supplier_mdm_code: str | None
|
supplier_mdm_code: str | None
|
||||||
process_result: str = ""
|
process_result: str = ""
|
||||||
|
process_time: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class MemoryContractRepo:
|
# 判断是否只录协议行:contract_entry_status=0 且已有合同号。
|
||||||
def __init__(self, rows: list[PurchaseContractRow]):
|
def _is_protocol_rerun(row: PurchaseContractRow) -> bool:
|
||||||
self.table3 = {row.fid: row for row in rows}
|
if row.contract_entry_status is not None and int(row.contract_entry_status) == 0:
|
||||||
self.table4: list[dict] = []
|
return bool((row.contract_no or "").strip())
|
||||||
self._next_fid = 1
|
return False
|
||||||
|
|
||||||
def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]:
|
|
||||||
from group import is_claimable
|
|
||||||
|
|
||||||
pending = sorted(
|
|
||||||
(row for row in self.table3.values() if is_claimable(row)),
|
|
||||||
key=lambda row: row.fid,
|
|
||||||
)
|
|
||||||
if limit is not None:
|
|
||||||
pending = pending[:limit]
|
|
||||||
for row in pending:
|
|
||||||
row.process_status = 3
|
|
||||||
return pending
|
|
||||||
|
|
||||||
def save_group(self, payload: Any) -> None:
|
|
||||||
for row in payload.table4_rows:
|
|
||||||
item = dict(row)
|
|
||||||
item["fid"] = self._next_fid
|
|
||||||
self._next_fid += 1
|
|
||||||
self.table4.append(item)
|
|
||||||
for fid in payload.fids:
|
|
||||||
target = self.table3[fid]
|
|
||||||
target.process_status = payload.process_status
|
|
||||||
target.process_result = payload.process_result
|
|
||||||
|
|
||||||
|
|
||||||
_SELECT_PENDING = """
|
# 判断内存行是否可领:0,或 3 且 process_time 已过租约。协议重录行永不领。
|
||||||
SELECT
|
def _is_claimable(row: PurchaseContractRow, now: datetime) -> bool:
|
||||||
fid, oms_id, supplier_id, supplier_name,
|
if _is_protocol_rerun(row):
|
||||||
contract_sign_date, contract_start_date, contract_end_date,
|
return False
|
||||||
payment_term, goods_id, goods_name, reg_no, spec, unit,
|
if int(row.process_status or 0) == 0:
|
||||||
qty, unit_price, purchaser_id, purchaser_name, planner_id, planner_name,
|
return True
|
||||||
contract_entry_status, contract_no, process_status, org_code,
|
if int(row.process_status or 0) == 3 and row.process_time is not None:
|
||||||
goods_mdm_code, supplier_mdm_code,
|
return now - row.process_time >= timedelta(minutes=LEASE_MINUTES)
|
||||||
NVL(process_result, '') AS process_result
|
return False
|
||||||
FROM rpa_purchase_contract_data
|
|
||||||
WHERE process_status = 0
|
|
||||||
AND NOT (NVL(contract_entry_status, -1) = 0
|
|
||||||
AND contract_no IS NOT NULL
|
|
||||||
AND LENGTH(TRIM(contract_no)) > 0)
|
|
||||||
ORDER BY fid
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SELECT_PENDING_LIMIT = f"""
|
|
||||||
SELECT * FROM (
|
|
||||||
{_SELECT_PENDING}
|
|
||||||
) WHERE ROWNUM <= :limit
|
|
||||||
"""
|
|
||||||
|
|
||||||
_CLAIM_SQL = """
|
|
||||||
UPDATE rpa_purchase_contract_data
|
|
||||||
SET process_status = 3,
|
|
||||||
process_time = SYSTIMESTAMP,
|
|
||||||
update_time = SYSTIMESTAMP
|
|
||||||
WHERE fid = :fid AND process_status = 0
|
|
||||||
"""
|
|
||||||
|
|
||||||
_INSERT_TABLE4_SQL = """
|
|
||||||
INSERT INTO rpa_contract_agreement_result (
|
|
||||||
oms_id, action_code, contract_status, contract_fail_reason, contract_no,
|
|
||||||
agreement_status, agreement_fail_reason, agreement_id, agreement_dtl_id,
|
|
||||||
push_status, create_time, update_time,
|
|
||||||
create_user_id, create_user_name, update_user_id, update_user_name, org_code
|
|
||||||
) VALUES (
|
|
||||||
:oms_id, :action_code, :contract_status, :contract_fail_reason, :contract_no,
|
|
||||||
:agreement_status, :agreement_fail_reason, :agreement_id, :agreement_dtl_id,
|
|
||||||
0, SYSTIMESTAMP, SYSTIMESTAMP,
|
|
||||||
0, '合同班', 0, '合同班', :org_code
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_COMPLETE_SQL = """
|
|
||||||
UPDATE rpa_purchase_contract_data
|
|
||||||
SET process_status = :process_status,
|
|
||||||
process_result = :process_result,
|
|
||||||
process_time = SYSTIMESTAMP,
|
|
||||||
update_time = SYSTIMESTAMP
|
|
||||||
WHERE fid = :fid AND process_status = 3
|
|
||||||
"""
|
|
||||||
|
|
||||||
_TABLE4_BIND_KEYS = (
|
|
||||||
"oms_id",
|
|
||||||
"action_code",
|
|
||||||
"contract_status",
|
|
||||||
"contract_fail_reason",
|
|
||||||
"contract_no",
|
|
||||||
"agreement_status",
|
|
||||||
"agreement_fail_reason",
|
|
||||||
"agreement_id",
|
|
||||||
"agreement_dtl_id",
|
|
||||||
"org_code",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
# 回滚当前事务。回滚本身失败则忽略。
|
||||||
|
def _rollback(conn) -> None:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 把查询结果行收成 PurchaseContractRow。
|
||||||
def _row_from_record(cols: list[str], rec) -> PurchaseContractRow:
|
def _row_from_record(cols: list[str], rec) -> PurchaseContractRow:
|
||||||
data = dict(zip(cols, rec))
|
data = dict(zip(cols, rec))
|
||||||
return PurchaseContractRow(
|
return PurchaseContractRow(
|
||||||
|
|
@ -171,68 +183,168 @@ def _row_from_record(cols: list[str], rec) -> PurchaseContractRow:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OracleContractRepo:
|
# 合同班次的内存仓库,测合单和班次编排,不测 SQL。
|
||||||
def __init__(self, connect: Callable):
|
class MemoryContractRepo:
|
||||||
self._connect = connect
|
# 用给定的表 3 行初始化。同一 fid 后写覆盖先写。
|
||||||
|
def __init__(self, rows: list[PurchaseContractRow]):
|
||||||
|
self.table3 = {row.fid: row for row in rows}
|
||||||
|
self.table4: list[dict] = []
|
||||||
|
self.shift_errors: list[ShiftError] = []
|
||||||
|
self._next_fid = 1
|
||||||
|
|
||||||
|
# 领取待录合同任务,把 process_status 从 0 或过期 3 改成 3。
|
||||||
|
#
|
||||||
|
# 未过租约的 3 不领。协议重录行不领。limit 为空表示不限制条数。now 只给单测钉住时钟。
|
||||||
|
def claim_pending(
|
||||||
|
self, limit: int | None = None, *, now: datetime | None = None
|
||||||
|
) -> list[PurchaseContractRow]:
|
||||||
|
clock = now or datetime.now()
|
||||||
|
pending = sorted(
|
||||||
|
(row for row in self.table3.values() if _is_claimable(row, clock)),
|
||||||
|
key=lambda row: row.fid,
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
pending = pending[:limit]
|
||||||
|
for row in pending:
|
||||||
|
row.process_status = 3
|
||||||
|
row.process_time = clock
|
||||||
|
return pending
|
||||||
|
|
||||||
|
# 把合单结果写入内存表 4,并改表 3 的 process_status。
|
||||||
|
def save_group(self, payload: Any) -> None:
|
||||||
|
for row in payload.table4_rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["fid"] = self._next_fid
|
||||||
|
self._next_fid += 1
|
||||||
|
self.table4.append(item)
|
||||||
|
for fid in payload.fids:
|
||||||
|
target = self.table3[fid]
|
||||||
|
target.process_status = payload.process_status
|
||||||
|
target.process_result = payload.process_result
|
||||||
|
|
||||||
|
# 记下未捕获异常原文。不改表 3 / 表 4。
|
||||||
|
def save_shift_errors(self, items: list[ShiftError]) -> None:
|
||||||
|
self.shift_errors.extend(items)
|
||||||
|
|
||||||
|
|
||||||
|
# 合同班次连 Oracle 的仓库,管表 3 领取和表 4 写入。
|
||||||
|
#
|
||||||
|
# 连接从 rpa_oracle 连接池取。领取戳是 process_status=3。
|
||||||
|
# 进程退出后行停在 3,超过租约才能再领。
|
||||||
|
class OracleContractRepo:
|
||||||
|
# 挂上连接池。方法里按次 acquire,班次 session 内复用一条。
|
||||||
|
def __init__(self, pool):
|
||||||
|
self._pool = pool
|
||||||
|
self._held = None
|
||||||
|
|
||||||
|
# 按环境变量建班次池。缺 HOST / 用户 / 密码抛「数据库不可用」。
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "OracleContractRepo":
|
def from_env(cls) -> "OracleContractRepo":
|
||||||
oracle_dsn()
|
return cls(pool=job_pool())
|
||||||
return cls(connect=connect_from_env)
|
|
||||||
|
# 未捕获异常原文进共用审计表。独立事务,失败只记日志。
|
||||||
|
def save_shift_errors(self, items: list[ShiftError]) -> None:
|
||||||
|
write_shift_errors(self._pool, items)
|
||||||
|
|
||||||
|
# 班次内借一条连接,领取和落表共用。每笔 save_group 单独提交。
|
||||||
|
@contextmanager
|
||||||
|
def session(self):
|
||||||
|
if self._held is not None:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
with self._pool.acquire() as conn:
|
||||||
|
self._held = conn
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._held = None
|
||||||
|
|
||||||
|
# 有 session 时交出已借连接,否则从池里新借一条。
|
||||||
|
def _acquire(self):
|
||||||
|
if self._held is not None:
|
||||||
|
|
||||||
|
# 交出班次已借的那条连接,退出时不归还。
|
||||||
|
@contextmanager
|
||||||
|
def _held():
|
||||||
|
yield self._held
|
||||||
|
|
||||||
|
return _held()
|
||||||
|
return self._pool.acquire()
|
||||||
|
|
||||||
def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]:
|
def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]:
|
||||||
conn = self._connect()
|
"""领取待录合同任务,把表 3 的 process_status 从 0 改成 3。
|
||||||
|
|
||||||
|
使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。
|
||||||
|
更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。
|
||||||
|
进程退出后行停在 3,超过租约(process_time)才能再领。
|
||||||
|
limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。
|
||||||
|
"""
|
||||||
|
binds: dict = {"lease_minutes": LEASE_MINUTES}
|
||||||
|
try:
|
||||||
|
with self._acquire() as conn:
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
if limit is None:
|
if limit is None:
|
||||||
cur.execute(_SELECT_PENDING)
|
cur.execute(_SELECT_PENDING, binds)
|
||||||
else:
|
else:
|
||||||
cur.execute(_SELECT_PENDING_LIMIT, {"limit": int(limit)})
|
cur.execute(
|
||||||
|
_SELECT_PENDING_LIMIT,
|
||||||
|
{**binds, "limit": int(limit)},
|
||||||
|
)
|
||||||
cols = [item[0].lower() for item in cur.description]
|
cols = [item[0].lower() for item in cur.description]
|
||||||
fetched = [_row_from_record(cols, rec) for rec in cur.fetchall()]
|
fetched = [_row_from_record(cols, rec) for rec in cur.fetchall()]
|
||||||
claimed: list[PurchaseContractRow] = []
|
if fetched:
|
||||||
for row in fetched:
|
update_by_fid_chunks(
|
||||||
cur.execute(_CLAIM_SQL, {"fid": row.fid})
|
cur,
|
||||||
if cur.rowcount == 1:
|
_CLAIM_SQL,
|
||||||
row.process_status = 3
|
[row.fid for row in fetched],
|
||||||
claimed.append(row)
|
|
||||||
conn.commit()
|
|
||||||
return claimed
|
|
||||||
except Exception as exc:
|
|
||||||
try:
|
|
||||||
conn.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def save_group(self, payload: Any) -> None:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
for row in payload.table4_rows:
|
|
||||||
cur.execute(
|
|
||||||
_INSERT_TABLE4_SQL,
|
|
||||||
{key: row.get(key) for key in _TABLE4_BIND_KEYS},
|
|
||||||
)
|
)
|
||||||
for fid in payload.fids:
|
for row in fetched:
|
||||||
cur.execute(
|
row.process_status = 3
|
||||||
_COMPLETE_SQL,
|
conn.commit()
|
||||||
|
return fetched
|
||||||
|
except Exception as exc:
|
||||||
|
_rollback(conn)
|
||||||
|
if isinstance(exc, RepoUnavailable):
|
||||||
|
raise
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
except RepoUnavailable:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
# 把合单结果写入表 4,并把表 3 的 process_status 改成 1 或 2。
|
||||||
|
#
|
||||||
|
# 表 4 插入和表 3 更新各一次 executemany,同一连接同一事务。
|
||||||
|
# 某一侧没有行就跳过那一次调用。库故障对外「数据库不可用」,不含 ORA 原文。
|
||||||
|
def save_group(self, payload: Any) -> None:
|
||||||
|
table4_binds = [
|
||||||
|
{key: row.get(key) for key in _TABLE4_BIND_KEYS}
|
||||||
|
for row in payload.table4_rows
|
||||||
|
]
|
||||||
|
complete_binds = [
|
||||||
{
|
{
|
||||||
"process_status": payload.process_status,
|
"process_status": payload.process_status,
|
||||||
"process_result": payload.process_result,
|
"process_result": payload.process_result,
|
||||||
"fid": fid,
|
"fid": fid,
|
||||||
},
|
}
|
||||||
)
|
for fid in payload.fids
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
with self._acquire() as conn:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if table4_binds:
|
||||||
|
cur.executemany(_INSERT_TABLE4_SQL, table4_binds)
|
||||||
|
if complete_binds:
|
||||||
|
cur.executemany(_COMPLETE_SQL, complete_binds)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
try:
|
_rollback(conn)
|
||||||
conn.rollback()
|
if isinstance(exc, RepoUnavailable):
|
||||||
except Exception:
|
raise
|
||||||
pass
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
except RepoUnavailable:
|
||||||
finally:
|
raise
|
||||||
conn.close()
|
except Exception as exc:
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,75 @@
|
||||||
"""合同班次入口:领表 3、调页面流、写表 4。不推接口 4。"""
|
# 合同班次入口:领表 3、调页面流、写表 4。不推接口 4。
|
||||||
|
#
|
||||||
|
# 崩在 process_status=3。账密读 CONTRACT_USERNAME / CONTRACT_PASSWORD,缺则失败。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
from apa_browser_adapter import ApaBrowserAdapter
|
from apa_browser_adapter import ApaBrowserAdapter
|
||||||
from contract_create_node import CONTRACT_URL, ContractCreateInput, build_flow_context
|
from contract_create_node import CONTRACT_URL, ContractCreateInput, build_flow_context
|
||||||
from contract_draft import ContractDraft
|
from contract_draft import ContractDraft
|
||||||
from contract_flow_steps import run_contract_create_flow
|
from contract_flow_steps import run_contract_create_flow
|
||||||
from db import load_runtime_env
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rpa_oracle import load_job_env
|
||||||
from job import outcome_from_run, run_batch
|
from job import outcome_from_run, run_batch
|
||||||
from persist import ContractOutcome
|
from persist import KIND_DILIGENCE, KIND_HOLD, KIND_OK, LOOKUP_ONLY_RESULT, ContractOutcome
|
||||||
from repo import OracleContractRepo
|
from repo import OracleContractRepo
|
||||||
|
|
||||||
|
DEFAULT_CLAIM_LIMIT = 10
|
||||||
|
|
||||||
def production_runner(browser: ApaBrowserAdapter, draft: ContractDraft) -> ContractOutcome:
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
"""解析合同班次参数。不传 --limit 时按行领 10 条,不再全部领走。"""
|
||||||
|
parser = argparse.ArgumentParser(description="合同班次:领表 3、按供应商合单、写表 4")
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_CLAIM_LIMIT,
|
||||||
|
help="本班最多领取 N 行,默认 10",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def contract_lookup_title(draft: ContractDraft) -> str:
|
||||||
|
"""拼查重标题:客商名 + 采购协议 + 签订日。不含货主和起止日,也不是合单键。"""
|
||||||
|
who = (draft.customer_name or draft.supplier_name or "").strip()
|
||||||
|
sign = (draft.contract_sign_date or draft.order_date or "").replace("-", "")
|
||||||
|
return f"{who}采购协议{sign}"
|
||||||
|
|
||||||
|
|
||||||
|
def decide_existing_contracts(
|
||||||
|
numbers: list[str], *, lookup_only: bool
|
||||||
|
) -> ContractOutcome | None:
|
||||||
|
"""按「我的合同」命中行决定这一组。
|
||||||
|
|
||||||
|
0 条且还可以起草时返回 None,调用方才走 S06–S12。
|
||||||
|
恰好 1 条复用该合同号,只落表 4。多条停在处理中,不取最新一条。
|
||||||
|
只查号时 0 条也停在处理中。
|
||||||
|
"""
|
||||||
|
hits = list(numbers)
|
||||||
|
if len(hits) > 1:
|
||||||
|
return ContractOutcome(KIND_HOLD, None, "我的合同命中多条")
|
||||||
|
if len(hits) == 1:
|
||||||
|
number = (hits[0] or "").strip()
|
||||||
|
if not number:
|
||||||
|
return ContractOutcome(KIND_HOLD, None, LOOKUP_ONLY_RESULT)
|
||||||
|
return ContractOutcome(KIND_OK, number, None)
|
||||||
|
if lookup_only:
|
||||||
|
return ContractOutcome(KIND_HOLD, None, LOOKUP_ONLY_RESULT)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pull_back_to_draft(browser) -> None:
|
||||||
|
"""一组开始前离开上一张合同的页面,回到起草页。"""
|
||||||
|
from contract_common import FORM_PAGE_URL
|
||||||
|
|
||||||
|
browser.open_url(FORM_PAGE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def _flow_context(draft: ContractDraft):
|
||||||
|
"""按这组草稿建页面上下文。账密仍只认环境变量。"""
|
||||||
spec = ContractCreateInput(
|
spec = ContractCreateInput(
|
||||||
job_id="contract-batch",
|
job_id="contract-batch",
|
||||||
supplier_name=draft.supplier_name,
|
supplier_name=draft.supplier_name,
|
||||||
|
|
@ -19,19 +77,93 @@ def production_runner(browser: ApaBrowserAdapter, draft: ContractDraft) -> Contr
|
||||||
source_type=draft.source_type or "",
|
source_type=draft.source_type or "",
|
||||||
submit_mode="submit",
|
submit_mode="submit",
|
||||||
)
|
)
|
||||||
ctx = build_flow_context(spec, draft)
|
return build_flow_context(spec, draft)
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_my_contracts(browser, draft: ContractDraft) -> list[str]:
|
||||||
|
"""进起草前查「我的合同」。命中行的合同号原样返回,不取最新一条。"""
|
||||||
|
from contract_number import (
|
||||||
|
_open_my_contracts,
|
||||||
|
_scrape_my_contract_rows,
|
||||||
|
_search_my_contracts,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = _flow_context(draft)
|
||||||
|
_open_my_contracts(browser, ctx)
|
||||||
|
_search_my_contracts(
|
||||||
|
browser,
|
||||||
|
contract_lookup_title(draft),
|
||||||
|
(draft.customer_code or "").strip(),
|
||||||
|
)
|
||||||
|
rows = _scrape_my_contract_rows(browser)
|
||||||
|
return [str(row.get("no") or "") for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_and_read_number(browser, draft: ContractDraft) -> ContractOutcome:
|
||||||
|
"""0 条时走 S06–S12。提交后读不到号则停在处理中,下一班只查号。"""
|
||||||
|
_pull_back_to_draft(browser)
|
||||||
|
ctx = _flow_context(draft)
|
||||||
|
|
||||||
def run() -> str:
|
def run() -> str:
|
||||||
|
"""在已打开的浏览器里跑起草并返回合同号。"""
|
||||||
return run_contract_create_flow(browser, ctx)
|
return run_contract_create_flow(browser, ctx)
|
||||||
|
|
||||||
return outcome_from_run(run)
|
outcome = outcome_from_run(run)
|
||||||
|
if outcome.kind == KIND_OK and (outcome.contract_no or "").strip():
|
||||||
|
return outcome
|
||||||
|
if outcome.kind == KIND_DILIGENCE:
|
||||||
|
return outcome
|
||||||
|
if outcome.kind == KIND_FAIL and "未读取到真实合同编号" in (outcome.reason or ""):
|
||||||
|
return ContractOutcome(KIND_HOLD, None, LOOKUP_ONLY_RESULT, outcome.error_text)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def production_runner(
|
||||||
load_runtime_env()
|
browser: ApaBrowserAdapter,
|
||||||
|
draft: ContractDraft,
|
||||||
|
*,
|
||||||
|
find_contracts=None,
|
||||||
|
create=None,
|
||||||
|
) -> ContractOutcome:
|
||||||
|
"""每个合单组先拉回起草页,再查「我的合同」。
|
||||||
|
|
||||||
|
查重三态:0 条才走 S06–S12;恰好 1 条只落表 4,不起草第二份;
|
||||||
|
多条停在 process_status=3,不新建,也不取最新一条。
|
||||||
|
标题是客商名 + 「采购协议」 + 签订日,不含货主和起止日。标题和客商不是合单键。
|
||||||
|
提交后读不到号也停在 3,下一班只查号。
|
||||||
|
"""
|
||||||
|
if find_contracts is None:
|
||||||
|
_pull_back_to_draft(browser)
|
||||||
|
numbers = _lookup_my_contracts(browser, draft)
|
||||||
|
else:
|
||||||
|
numbers = list(find_contracts(browser, draft))
|
||||||
|
decided = decide_existing_contracts(
|
||||||
|
numbers,
|
||||||
|
lookup_only=bool(getattr(draft, "lookup_only", False)),
|
||||||
|
)
|
||||||
|
if decided is not None:
|
||||||
|
return decided
|
||||||
|
if create is None:
|
||||||
|
return _create_and_read_number(browser, draft)
|
||||||
|
number = (create(browser, draft) or "").strip()
|
||||||
|
if number:
|
||||||
|
return ContractOutcome(KIND_OK, number, None)
|
||||||
|
return ContractOutcome(KIND_HOLD, None, LOOKUP_ONLY_RESULT)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None, repo=None, runner=None) -> int:
|
||||||
|
"""领表 3、按供应商合单录合同、写表 4。有失败则进程退出码 1。
|
||||||
|
|
||||||
|
不传 --limit 时最多领 10 行。传入 repo 和 runner 时不打开浏览器。
|
||||||
|
"""
|
||||||
|
args = parse_args(argv)
|
||||||
|
load_job_env(Path(__file__).resolve().parent)
|
||||||
|
if repo is None:
|
||||||
repo = OracleContractRepo.from_env()
|
repo = OracleContractRepo.from_env()
|
||||||
|
if runner is None:
|
||||||
browser = ApaBrowserAdapter(url=CONTRACT_URL, flow="CONTRACT_CREATE")
|
browser = ApaBrowserAdapter(url=CONTRACT_URL, flow="CONTRACT_CREATE")
|
||||||
summary = run_batch(repo, runner=lambda draft: production_runner(browser, draft))
|
runner = lambda draft: production_runner(browser, draft)
|
||||||
|
summary = run_batch(repo, runner=runner, limit=args.limit)
|
||||||
print(
|
print(
|
||||||
f"claimed={summary.claimed} groups={summary.groups} "
|
f"claimed={summary.claimed} groups={summary.groups} "
|
||||||
f"ok={summary.ok} failed={summary.failed}"
|
f"ok={summary.ok} failed={summary.failed}"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,31 @@
|
||||||
|
# 合同浏览器适配:profile 在仓库外、调试口稳定、端口/DevTools 用轮询。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from apa_browser_adapter import (
|
from apa_browser_adapter import (
|
||||||
|
ApaBrowserAdapter,
|
||||||
REPO_ROOT,
|
REPO_ROOT,
|
||||||
chrome_debug_port,
|
chrome_debug_port,
|
||||||
resolve_chrome_profile_base,
|
resolve_chrome_profile_base,
|
||||||
resolve_chrome_profile_dir,
|
resolve_chrome_profile_dir,
|
||||||
|
wait_debug_port,
|
||||||
|
wait_devtools_port,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 合同 ApaBrowserAdapter 就是 rpa_browser 里的那一份。
|
||||||
|
def test_adapter_class_is_shared_rpa_browser():
|
||||||
|
from rpa_browser.adapter import ApaBrowserAdapter as Shared
|
||||||
|
|
||||||
|
assert ApaBrowserAdapter is Shared
|
||||||
|
|
||||||
|
|
||||||
|
# 未配环境变量时 Chrome 用户目录落在仓库外 rpa-chrome-profile。
|
||||||
def test_default_chrome_profile_is_outside_repo():
|
def test_default_chrome_profile_is_outside_repo():
|
||||||
profile = resolve_chrome_profile_base(requested="")
|
profile = resolve_chrome_profile_base(requested="")
|
||||||
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve()
|
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve()
|
||||||
|
|
@ -15,6 +33,7 @@ def test_default_chrome_profile_is_outside_repo():
|
||||||
assert profile != REPO_ROOT.resolve()
|
assert profile != REPO_ROOT.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# RPA_CHROME_PROFILE 指到仓库内时改用仓库外默认目录。
|
||||||
def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
|
def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
|
||||||
inside = REPO_ROOT / "rpa_contract" / "screenshots" / "chrome-profile"
|
inside = REPO_ROOT / "rpa_contract" / "screenshots" / "chrome-profile"
|
||||||
monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False)
|
monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False)
|
||||||
|
|
@ -23,6 +42,7 @@ def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
|
||||||
assert not _is_relative_to(profile, REPO_ROOT)
|
assert not _is_relative_to(profile, REPO_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
# RPA_CHROME_PROFILE 在仓库外时按原路径使用。
|
||||||
def test_env_path_outside_repo_is_kept(tmp_path):
|
def test_env_path_outside_repo_is_kept(tmp_path):
|
||||||
outside = tmp_path / "custom-chrome"
|
outside = tmp_path / "custom-chrome"
|
||||||
outside.mkdir()
|
outside.mkdir()
|
||||||
|
|
@ -30,6 +50,7 @@ def test_env_path_outside_repo_is_kept(tmp_path):
|
||||||
assert profile == outside.resolve()
|
assert profile == outside.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# 同一 flow 的 profile 目录稳定,且不落在仓库内。
|
||||||
def test_chrome_profile_dir_is_stable_per_flow():
|
def test_chrome_profile_dir_is_stable_per_flow():
|
||||||
first = resolve_chrome_profile_dir("CONTRACT_CREATE")
|
first = resolve_chrome_profile_dir("CONTRACT_CREATE")
|
||||||
second = resolve_chrome_profile_dir("CONTRACT_CREATE")
|
second = resolve_chrome_profile_dir("CONTRACT_CREATE")
|
||||||
|
|
@ -38,12 +59,60 @@ def test_chrome_profile_dir_is_stable_per_flow():
|
||||||
assert not _is_relative_to(first, REPO_ROOT)
|
assert not _is_relative_to(first, REPO_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
# CONTRACT_CREATE 默认调试口 9333,同一 flow 两次相同。
|
||||||
def test_chrome_debug_port_is_stable_per_flow(monkeypatch):
|
def test_chrome_debug_port_is_stable_per_flow(monkeypatch):
|
||||||
monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False)
|
monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False)
|
||||||
assert chrome_debug_port("CONTRACT_CREATE") == 9333
|
assert chrome_debug_port("CONTRACT_CREATE") == 9333
|
||||||
assert chrome_debug_port("CONTRACT_CREATE") == chrome_debug_port("CONTRACT_CREATE")
|
assert chrome_debug_port("CONTRACT_CREATE") == chrome_debug_port("CONTRACT_CREATE")
|
||||||
|
|
||||||
|
|
||||||
|
# 端口已开时 wait_debug_port 立刻返回 True。
|
||||||
|
def test_wait_debug_port_true_when_open():
|
||||||
|
srv = socket.socket()
|
||||||
|
srv.bind(("127.0.0.1", 0))
|
||||||
|
srv.listen(1)
|
||||||
|
port = srv.getsockname()[1]
|
||||||
|
try:
|
||||||
|
assert wait_debug_port(port, timeout=1.0) is True
|
||||||
|
finally:
|
||||||
|
srv.close()
|
||||||
|
|
||||||
|
|
||||||
|
# 端口一直关着时短超时轮询后返回 False,整段远小于无条件 sleep(1)。
|
||||||
|
def test_wait_debug_port_false_on_timeout():
|
||||||
|
port = _closed_port()
|
||||||
|
started = time.monotonic()
|
||||||
|
assert wait_debug_port(port, timeout=0.25) is False
|
||||||
|
assert time.monotonic() - started < 0.8
|
||||||
|
|
||||||
|
|
||||||
|
# profile 里已有 DevToolsActivePort 时立刻读出端口。
|
||||||
|
def test_wait_devtools_port_reads_marker(tmp_path):
|
||||||
|
(tmp_path / "DevToolsActivePort").write_text("9333\n/devtools/browser/x\n", encoding="utf-8")
|
||||||
|
assert wait_devtools_port(tmp_path, timeout=0.5) == 9333
|
||||||
|
|
||||||
|
|
||||||
|
# DevToolsActivePort 稍后出现时轮询仍能读到端口。
|
||||||
|
def test_wait_devtools_port_picks_up_late_marker(tmp_path):
|
||||||
|
# 稍后写入 DevToolsActivePort,用来证明 wait 是轮询不是一次读。
|
||||||
|
def _write() -> None:
|
||||||
|
time.sleep(0.15)
|
||||||
|
(tmp_path / "DevToolsActivePort").write_text("9335\n", encoding="utf-8")
|
||||||
|
|
||||||
|
threading.Thread(target=_write, daemon=True).start()
|
||||||
|
assert wait_devtools_port(tmp_path, timeout=1.0) == 9335
|
||||||
|
|
||||||
|
|
||||||
|
# 借一个刚释放的本机端口,调用时应当是关着的。
|
||||||
|
def _closed_port() -> int:
|
||||||
|
srv = socket.socket()
|
||||||
|
srv.bind(("127.0.0.1", 0))
|
||||||
|
port = srv.getsockname()[1]
|
||||||
|
srv.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
# path 是否落在 root 目录树内。
|
||||||
def _is_relative_to(path: Path, root: Path) -> bool:
|
def _is_relative_to(path: Path, root: Path) -> bool:
|
||||||
try:
|
try:
|
||||||
path.resolve().relative_to(root.resolve())
|
path.resolve().relative_to(root.resolve())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 合同系统账密:只认环境变量或入参,仓库里没有默认密码。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contract_create_node import (
|
||||||
|
ContractCreateError,
|
||||||
|
ContractCreateInput,
|
||||||
|
resolve_contract_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 不设 CONTRACT_USERNAME / CONTRACT_PASSWORD 且无入参时入口失败。
|
||||||
|
def test_resolve_credentials_requires_env(monkeypatch):
|
||||||
|
monkeypatch.delenv("CONTRACT_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CONTRACT_PASSWORD", raising=False)
|
||||||
|
with pytest.raises(ContractCreateError, match="CONTRACT_USERNAME"):
|
||||||
|
resolve_contract_credentials()
|
||||||
|
|
||||||
|
|
||||||
|
# 环境变量齐时返回账密。
|
||||||
|
def test_resolve_credentials_uses_env(monkeypatch):
|
||||||
|
monkeypatch.setenv("CONTRACT_USERNAME", "u")
|
||||||
|
monkeypatch.setenv("CONTRACT_PASSWORD", "p")
|
||||||
|
assert resolve_contract_credentials() == ("u", "p")
|
||||||
|
|
||||||
|
|
||||||
|
# 入参优先于环境变量。
|
||||||
|
def test_resolve_credentials_prefers_explicit(monkeypatch):
|
||||||
|
monkeypatch.setenv("CONTRACT_USERNAME", "env-u")
|
||||||
|
monkeypatch.setenv("CONTRACT_PASSWORD", "env-p")
|
||||||
|
assert resolve_contract_credentials("u", "p") == ("u", "p")
|
||||||
|
|
||||||
|
|
||||||
|
# ContractCreateInput 不再内置测试账密。
|
||||||
|
def test_create_input_has_no_default_password():
|
||||||
|
spec = ContractCreateInput(
|
||||||
|
job_id="j",
|
||||||
|
supplier_name="s",
|
||||||
|
order_date="2026-10-01",
|
||||||
|
source_type="",
|
||||||
|
)
|
||||||
|
assert spec.username == ""
|
||||||
|
assert spec.password == ""
|
||||||
|
|
||||||
|
|
||||||
|
# 合同包源码里搜不到默认账号 10007228。
|
||||||
|
def test_source_has_no_hardcoded_contract_account():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
skipped = {"tests", ".venv", "__pycache__"}
|
||||||
|
hits: list[str] = []
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if any(part in skipped for part in path.parts):
|
||||||
|
continue
|
||||||
|
if path.suffix not in {".py", ".md", ".json"}:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if "10007228" in text:
|
||||||
|
hits.append(str(path.relative_to(root)))
|
||||||
|
assert hits == []
|
||||||
|
|
@ -86,6 +86,29 @@ def test_group_by_supplier_keeps_fid_order_and_first_header():
|
||||||
assert draft.belong_year == "2026"
|
assert draft.belong_year == "2026"
|
||||||
|
|
||||||
|
|
||||||
|
# 同供应商但货主或三个日期任一不同,不得收成同一张草稿。
|
||||||
|
def test_same_supplier_splits_when_org_or_dates_differ():
|
||||||
|
rows = [
|
||||||
|
_row(fid=1, supplier_id="A"),
|
||||||
|
_row(fid=2, supplier_id="A", org_code="902", oms_id=2),
|
||||||
|
_row(fid=3, supplier_id="A", contract_sign_date=date(2026, 11, 1), oms_id=3),
|
||||||
|
_row(fid=4, supplier_id="A", contract_start_date=date(2026, 11, 2), oms_id=4),
|
||||||
|
_row(fid=5, supplier_id="A", contract_end_date=date(2028, 1, 1), oms_id=5),
|
||||||
|
_row(fid=6, supplier_id="A", oms_id=6, goods_id="G0009"),
|
||||||
|
]
|
||||||
|
groups = group_by_supplier(rows)
|
||||||
|
drafts = {
|
||||||
|
tuple(row.fid for row in group): draft_from_group(group) for group in groups
|
||||||
|
}
|
||||||
|
assert len(groups) == 5
|
||||||
|
assert (1, 6) in drafts
|
||||||
|
assert drafts[(1, 6)].amount == "2000.0000"
|
||||||
|
assert len(drafts[(1, 6)].goods_lines) == 2
|
||||||
|
for fid in (2, 3, 4, 5):
|
||||||
|
assert (fid,) in drafts
|
||||||
|
assert len(drafts[(fid,)].goods_lines) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_missing_mdm_reason():
|
def test_missing_mdm_reason():
|
||||||
rows = [_row(supplier_mdm_code="")]
|
rows = [_row(supplier_mdm_code="")]
|
||||||
assert missing_mdm_reason(rows) == "缺少供应商MDM码"
|
assert missing_mdm_reason(rows) == "缺少供应商MDM码"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
from datetime import date
|
# 合同班次:领取戳、合单、尽调失败、系统故障短码。
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from errors import DiligenceError, NodeError
|
from errors import DiligenceError, NodeError
|
||||||
from job import outcome_from_run, run_batch
|
from job import PAGE_FAILED, outcome_from_run, run_batch
|
||||||
from persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome, build_save
|
from persist import (
|
||||||
|
KIND_DILIGENCE,
|
||||||
|
KIND_FAIL,
|
||||||
|
KIND_OK,
|
||||||
|
LOOKUP_ONLY_RESULT,
|
||||||
|
ContractOutcome,
|
||||||
|
build_save,
|
||||||
|
)
|
||||||
from repo import MemoryContractRepo, PurchaseContractRow
|
from repo import MemoryContractRepo, PurchaseContractRow
|
||||||
|
from run_batch import (
|
||||||
|
DEFAULT_CLAIM_LIMIT,
|
||||||
|
contract_lookup_title,
|
||||||
|
main as batch_main,
|
||||||
|
production_runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条待录合同的表 3 行。
|
||||||
def _row(**overrides) -> PurchaseContractRow:
|
def _row(**overrides) -> PurchaseContractRow:
|
||||||
data = dict(
|
data = dict(
|
||||||
fid=1,
|
fid=1,
|
||||||
|
|
@ -39,6 +56,7 @@ def _row(**overrides) -> PurchaseContractRow:
|
||||||
return PurchaseContractRow(**data)
|
return PurchaseContractRow(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# process_status=0 领走后变成 3,协议重录行和已完成行不领。
|
||||||
def test_claim_pending_sets_processing_and_skips_protocol_rerun():
|
def test_claim_pending_sets_processing_and_skips_protocol_rerun():
|
||||||
repo = MemoryContractRepo(
|
repo = MemoryContractRepo(
|
||||||
[
|
[
|
||||||
|
|
@ -53,6 +71,46 @@ def test_claim_pending_sets_processing_and_skips_protocol_rerun():
|
||||||
assert repo.table3[3].process_status == 0
|
assert repo.table3[3].process_status == 0
|
||||||
|
|
||||||
|
|
||||||
|
# process_status=3 且 process_time 超过租约的行可以再领。
|
||||||
|
def test_claim_reclaims_expired_status_3():
|
||||||
|
now = datetime(2026, 10, 22, 12, 0, 0)
|
||||||
|
repo = MemoryContractRepo(
|
||||||
|
[_row(process_status=3, process_time=now - timedelta(minutes=31))]
|
||||||
|
)
|
||||||
|
claimed = repo.claim_pending(now=now)
|
||||||
|
assert [row.fid for row in claimed] == [1]
|
||||||
|
assert repo.table3[1].process_status == 3
|
||||||
|
|
||||||
|
|
||||||
|
# process_status=3 且未过租约的行不领。
|
||||||
|
def test_claim_skips_fresh_status_3():
|
||||||
|
now = datetime(2026, 10, 22, 12, 0, 0)
|
||||||
|
repo = MemoryContractRepo(
|
||||||
|
[_row(process_status=3, process_time=now - timedelta(minutes=1))]
|
||||||
|
)
|
||||||
|
claimed = repo.claim_pending(now=now)
|
||||||
|
assert claimed == []
|
||||||
|
assert repo.table3[1].process_status == 3
|
||||||
|
|
||||||
|
|
||||||
|
# 协议重录行即使停在过期 3 也不领。
|
||||||
|
def test_claim_skips_protocol_rerun_even_if_expired():
|
||||||
|
now = datetime(2026, 10, 22, 12, 0, 0)
|
||||||
|
repo = MemoryContractRepo(
|
||||||
|
[
|
||||||
|
_row(
|
||||||
|
process_status=3,
|
||||||
|
process_time=now - timedelta(minutes=31),
|
||||||
|
contract_entry_status=0,
|
||||||
|
contract_no="HT-OLD",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert repo.claim_pending(now=now) == []
|
||||||
|
assert repo.table3[1].process_status == 3
|
||||||
|
|
||||||
|
|
||||||
|
# 同一 oms_id 多行只插一条表 4,表 3 全部改成成功。
|
||||||
def test_save_group_inserts_one_table4_row_per_oms_id():
|
def test_save_group_inserts_one_table4_row_per_oms_id():
|
||||||
repo = MemoryContractRepo(
|
repo = MemoryContractRepo(
|
||||||
[
|
[
|
||||||
|
|
@ -69,11 +127,15 @@ def test_save_group_inserts_one_table4_row_per_oms_id():
|
||||||
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
|
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
|
||||||
assert {row["action_code"] for row in repo.table4} == {1}
|
assert {row["action_code"] for row in repo.table4} == {1}
|
||||||
assert {row["contract_status"] for row in repo.table4} == {1}
|
assert {row["contract_status"] for row in repo.table4} == {1}
|
||||||
|
assert {row["process_status"] for row in repo.table4} == {0}
|
||||||
|
assert {row["create_user_name"] for row in repo.table4} == {"系统管理员"}
|
||||||
|
assert {row["update_user_name"] for row in repo.table4} == {"系统管理员"}
|
||||||
assert {row["push_status"] for row in repo.table4} == {0}
|
assert {row["push_status"] for row in repo.table4} == {0}
|
||||||
assert repo.table3[1].process_status == 1
|
assert repo.table3[1].process_status == 1
|
||||||
assert repo.table3[3].process_status == 1
|
assert repo.table3[3].process_status == 1
|
||||||
|
|
||||||
|
|
||||||
|
# 同一供应商合单一次录入,协议重录行不进本班。
|
||||||
def test_job_merges_same_supplier_and_writes_table4():
|
def test_job_merges_same_supplier_and_writes_table4():
|
||||||
repo = MemoryContractRepo(
|
repo = MemoryContractRepo(
|
||||||
[
|
[
|
||||||
|
|
@ -84,6 +146,7 @@ def test_job_merges_same_supplier_and_writes_table4():
|
||||||
)
|
)
|
||||||
calls: list = []
|
calls: list = []
|
||||||
|
|
||||||
|
# 记下草稿并返回成功合同号。
|
||||||
def runner(draft):
|
def runner(draft):
|
||||||
calls.append(draft)
|
calls.append(draft)
|
||||||
return ContractOutcome(KIND_OK, "HT20261005001", None)
|
return ContractOutcome(KIND_OK, "HT20261005001", None)
|
||||||
|
|
@ -103,15 +166,19 @@ def test_job_merges_same_supplier_and_writes_table4():
|
||||||
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
|
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
|
||||||
assert {row["action_code"] for row in repo.table4} == {1}
|
assert {row["action_code"] for row in repo.table4} == {1}
|
||||||
assert {row["contract_status"] for row in repo.table4} == {1}
|
assert {row["contract_status"] for row in repo.table4} == {1}
|
||||||
|
assert {row["process_status"] for row in repo.table4} == {0}
|
||||||
|
assert {row["create_user_name"] for row in repo.table4} == {"系统管理员"}
|
||||||
assert {row["push_status"] for row in repo.table4} == {0}
|
assert {row["push_status"] for row in repo.table4} == {0}
|
||||||
assert repo.table3[1].process_status == 1
|
assert repo.table3[1].process_status == 1
|
||||||
assert repo.table3[2].process_status == 1
|
assert repo.table3[2].process_status == 1
|
||||||
assert repo.table3[3].process_status == 0
|
assert repo.table3[3].process_status == 0
|
||||||
|
|
||||||
|
|
||||||
|
# 尽调失败写客户未尽调,表 3 改成 2,表 4 起草失败。
|
||||||
def test_job_marks_diligence_fail():
|
def test_job_marks_diligence_fail():
|
||||||
repo = MemoryContractRepo([_row()])
|
repo = MemoryContractRepo([_row()])
|
||||||
|
|
||||||
|
# 返回未尽调结果。
|
||||||
def runner(draft):
|
def runner(draft):
|
||||||
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
|
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
|
||||||
|
|
||||||
|
|
@ -119,14 +186,18 @@ def test_job_marks_diligence_fail():
|
||||||
assert repo.table3[1].process_status == 2
|
assert repo.table3[1].process_status == 2
|
||||||
assert "客户未尽调" in repo.table3[1].process_result
|
assert "客户未尽调" in repo.table3[1].process_result
|
||||||
assert repo.table4[0]["contract_status"] == 3
|
assert repo.table4[0]["contract_status"] == 3
|
||||||
|
assert repo.table4[0]["process_status"] == 2
|
||||||
|
assert repo.table4[0]["update_user_name"] == "系统管理员"
|
||||||
assert repo.table4[0]["contract_no"] is None
|
assert repo.table4[0]["contract_no"] is None
|
||||||
assert repo.table4[0]["contract_fail_reason"] == "客户未尽调"
|
assert repo.table4[0]["contract_fail_reason"] == "客户未尽调"
|
||||||
|
|
||||||
|
|
||||||
|
# 缺供应商 MDM 码时不调页面,直接业务失败。
|
||||||
def test_job_skips_runner_when_mdm_missing():
|
def test_job_skips_runner_when_mdm_missing():
|
||||||
repo = MemoryContractRepo([_row(supplier_mdm_code="")])
|
repo = MemoryContractRepo([_row(supplier_mdm_code="")])
|
||||||
called = {"n": 0}
|
called = {"n": 0}
|
||||||
|
|
||||||
|
# 本用例不应被调用。
|
||||||
def runner(draft):
|
def runner(draft):
|
||||||
called["n"] += 1
|
called["n"] += 1
|
||||||
return ContractOutcome(KIND_OK, "HT-X", None)
|
return ContractOutcome(KIND_OK, "HT-X", None)
|
||||||
|
|
@ -137,7 +208,9 @@ def test_job_skips_runner_when_mdm_missing():
|
||||||
assert "缺少供应商MDM码" in repo.table3[1].process_result
|
assert "缺少供应商MDM码" in repo.table3[1].process_result
|
||||||
|
|
||||||
|
|
||||||
|
# 页面尽调失败映射成 KIND_DILIGENCE。
|
||||||
def test_outcome_from_run_maps_diligence():
|
def test_outcome_from_run_maps_diligence():
|
||||||
|
# 抛出未尽调。
|
||||||
def run():
|
def run():
|
||||||
raise DiligenceError("客户未尽调")
|
raise DiligenceError("客户未尽调")
|
||||||
|
|
||||||
|
|
@ -147,18 +220,22 @@ def test_outcome_from_run_maps_diligence():
|
||||||
assert out.contract_no is None
|
assert out.contract_no is None
|
||||||
|
|
||||||
|
|
||||||
|
# 提交成功但没读到合同号时记失败。
|
||||||
def test_outcome_from_run_maps_empty_number_to_fail():
|
def test_outcome_from_run_maps_empty_number_to_fail():
|
||||||
out = outcome_from_run(lambda: "")
|
out = outcome_from_run(lambda: "")
|
||||||
assert out.kind == KIND_FAIL
|
assert out.kind == KIND_FAIL
|
||||||
assert "未读取到真实合同编号" in (out.reason or "")
|
assert "未读取到真实合同编号" in (out.reason or "")
|
||||||
|
|
||||||
|
|
||||||
|
# 读到合同号时记成功。
|
||||||
def test_outcome_from_run_ok():
|
def test_outcome_from_run_ok():
|
||||||
out = outcome_from_run(lambda: "HT9")
|
out = outcome_from_run(lambda: "HT9")
|
||||||
assert out == ContractOutcome(KIND_OK, "HT9", None)
|
assert out == ContractOutcome(KIND_OK, "HT9", None)
|
||||||
|
|
||||||
|
|
||||||
|
# 页面 NodeError 仍写业务文案。
|
||||||
def test_outcome_from_run_maps_node_error():
|
def test_outcome_from_run_maps_node_error():
|
||||||
|
# 抛出客商加载失败。
|
||||||
def run():
|
def run():
|
||||||
raise NodeError("客商加载失败")
|
raise NodeError("客商加载失败")
|
||||||
|
|
||||||
|
|
@ -166,3 +243,218 @@ def test_outcome_from_run_maps_node_error():
|
||||||
assert out.kind == KIND_FAIL
|
assert out.kind == KIND_FAIL
|
||||||
assert "客商加载失败" in (out.reason or "")
|
assert "客商加载失败" in (out.reason or "")
|
||||||
|
|
||||||
|
|
||||||
|
# 未捕获异常只写 PAGE_FAILED,不含原文。
|
||||||
|
def test_outcome_from_run_maps_unexpected_to_short_code():
|
||||||
|
# 抛出带 ORA 原文的异常。
|
||||||
|
def run():
|
||||||
|
raise RuntimeError("ORA-00001 unique constraint (RPA.UK)")
|
||||||
|
|
||||||
|
out = outcome_from_run(run)
|
||||||
|
assert out.kind == KIND_FAIL
|
||||||
|
assert out.reason == PAGE_FAILED
|
||||||
|
assert "ORA-" not in (out.reason or "")
|
||||||
|
assert out.error_text == "ORA-00001 unique constraint (RPA.UK)"
|
||||||
|
|
||||||
|
|
||||||
|
# runner 抛未捕获异常时表 3/表 4 原因列只写 PAGE_FAILED。
|
||||||
|
def test_job_maps_unexpected_error_to_short_code():
|
||||||
|
repo = MemoryContractRepo([_row()])
|
||||||
|
|
||||||
|
# 模拟页面未捕获异常。
|
||||||
|
def runner(draft):
|
||||||
|
raise RuntimeError("traceback here")
|
||||||
|
|
||||||
|
run_batch(repo, runner=runner)
|
||||||
|
assert repo.table3[1].process_status == 2
|
||||||
|
assert repo.table3[1].process_result == PAGE_FAILED
|
||||||
|
assert repo.table4[0]["contract_fail_reason"] == PAGE_FAILED
|
||||||
|
assert len(repo.shift_errors) == 1
|
||||||
|
assert repo.shift_errors[0].shift_name == "合同"
|
||||||
|
assert repo.shift_errors[0].biz_key == "SUP001"
|
||||||
|
assert repo.shift_errors[0].error_code == PAGE_FAILED
|
||||||
|
assert repo.shift_errors[0].error_text == "traceback here"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_limit_caps_claim_at_10():
|
||||||
|
"""不传 --limit 时合同班最多领 10 行。"""
|
||||||
|
repo = MemoryContractRepo(
|
||||||
|
[
|
||||||
|
_row(fid=i, oms_id=2026100500 + i, supplier_id=f"SUP{i}")
|
||||||
|
for i in range(1, 16)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
seen: dict[str, int | None] = {}
|
||||||
|
original = repo.claim_pending
|
||||||
|
|
||||||
|
def claim_pending(limit=None, **kwargs):
|
||||||
|
"""记下入口传给领取的上限和实际领走的行数。"""
|
||||||
|
seen["limit"] = limit
|
||||||
|
claimed = original(limit=limit, **kwargs)
|
||||||
|
seen["count"] = len(claimed)
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
repo.claim_pending = claim_pending
|
||||||
|
|
||||||
|
def runner(_draft):
|
||||||
|
"""不打开页面,直接记失败。"""
|
||||||
|
return ContractOutcome(KIND_FAIL, None, "skip")
|
||||||
|
|
||||||
|
batch_main([], repo=repo, runner=runner)
|
||||||
|
assert seen["limit"] == DEFAULT_CLAIM_LIMIT
|
||||||
|
assert seen["count"] == 10
|
||||||
|
assert seen["count"] <= DEFAULT_CLAIM_LIMIT
|
||||||
|
|
||||||
|
|
||||||
|
def test_unique_existing_contract_does_not_draft_again():
|
||||||
|
"""对方已有唯一合同号、本地没写上时,重跑不起草第二份。"""
|
||||||
|
repo = MemoryContractRepo([_row()])
|
||||||
|
drafted: list[str] = []
|
||||||
|
|
||||||
|
def finder(_browser, draft):
|
||||||
|
"""标题不含货主和起止日。对方只有一份合同时返回该号。"""
|
||||||
|
title = contract_lookup_title(draft)
|
||||||
|
assert title == "示例供应商采购协议20261001"
|
||||||
|
assert "901" not in title
|
||||||
|
assert "20261002" not in title
|
||||||
|
assert "20270930" not in title
|
||||||
|
return ["HT-EXISTING"]
|
||||||
|
|
||||||
|
def creator(_browser, _draft):
|
||||||
|
"""本用例不应起草。"""
|
||||||
|
drafted.append("created")
|
||||||
|
return "HT-NEW"
|
||||||
|
|
||||||
|
def runner(draft):
|
||||||
|
"""用注入的查重,不打开浏览器。"""
|
||||||
|
return production_runner(None, draft, find_contracts=finder, create=creator)
|
||||||
|
|
||||||
|
summary = run_batch(repo, runner=runner)
|
||||||
|
assert drafted == []
|
||||||
|
assert summary.ok == 1
|
||||||
|
assert repo.table4[0]["contract_no"] == "HT-EXISTING"
|
||||||
|
assert repo.table3[1].process_status == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_many_contracts_stay_in_process():
|
||||||
|
"""命中多条时停在处理中,不新建,不取最新一条。"""
|
||||||
|
repo = MemoryContractRepo([_row()])
|
||||||
|
drafted: list[str] = []
|
||||||
|
|
||||||
|
def finder(_browser, _draft):
|
||||||
|
"""两行都在,调用方不能挑最新的那条。"""
|
||||||
|
return ["HT-OLD", "HT-NEW"]
|
||||||
|
|
||||||
|
def creator(_browser, _draft):
|
||||||
|
"""命中多条时不应起草。"""
|
||||||
|
drafted.append("created")
|
||||||
|
return "HT-PICKED"
|
||||||
|
|
||||||
|
def runner(draft):
|
||||||
|
"""用注入的查重,不打开浏览器。"""
|
||||||
|
return production_runner(None, draft, find_contracts=finder, create=creator)
|
||||||
|
|
||||||
|
run_batch(repo, runner=runner)
|
||||||
|
assert drafted == []
|
||||||
|
assert repo.table4 == []
|
||||||
|
assert repo.table3[1].process_status == 3
|
||||||
|
assert repo.table3[1].process_result == "我的合同命中多条"
|
||||||
|
assert repo.claim_pending() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_number_stays_in_process_and_next_shift_only_looks_up():
|
||||||
|
"""提交后读不到号停在处理中。下一班只查号,不再起草。"""
|
||||||
|
repo = MemoryContractRepo([_row()])
|
||||||
|
drafted: list[int] = []
|
||||||
|
found = {1: []}
|
||||||
|
|
||||||
|
def finder(_browser, draft):
|
||||||
|
"""第二班必须带着只查号标记。"""
|
||||||
|
if drafted:
|
||||||
|
assert draft.lookup_only is True
|
||||||
|
return found[1]
|
||||||
|
|
||||||
|
def creator(_browser, _draft):
|
||||||
|
"""只有第一班会进来,而且读不到号。"""
|
||||||
|
drafted.append(1)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def runner(draft):
|
||||||
|
"""用注入的查重和起草,不打开浏览器。"""
|
||||||
|
return production_runner(None, draft, find_contracts=finder, create=creator)
|
||||||
|
|
||||||
|
run_batch(repo, runner=runner)
|
||||||
|
assert drafted == [1]
|
||||||
|
assert repo.table4 == []
|
||||||
|
assert repo.table3[1].process_status == 3
|
||||||
|
assert repo.table3[1].process_result == LOOKUP_ONLY_RESULT
|
||||||
|
repo.table3[1].process_time = datetime.now() - timedelta(minutes=31)
|
||||||
|
found[1] = ["HT-LATER"]
|
||||||
|
run_batch(repo, runner=runner)
|
||||||
|
assert drafted == [1]
|
||||||
|
assert repo.table4[0]["contract_no"] == "HT-LATER"
|
||||||
|
assert repo.table3[1].process_status == 1
|
||||||
|
|
||||||
|
|
||||||
|
class HoldingContractRepo(MemoryContractRepo):
|
||||||
|
"""内存仓库加上可计数的 session,用来断言页面期间连接已归还。"""
|
||||||
|
|
||||||
|
def __init__(self, rows):
|
||||||
|
"""在内存行上加 held 计数。"""
|
||||||
|
super().__init__(rows)
|
||||||
|
self.held = 0
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session(self):
|
||||||
|
"""借连接时 held 加一,退出时减回。"""
|
||||||
|
self.held += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.held -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_runs_after_connection_is_returned():
|
||||||
|
"""两组页面执行时连接已归还,落表时才再借。"""
|
||||||
|
repo = HoldingContractRepo(
|
||||||
|
[
|
||||||
|
_row(fid=1, supplier_id="SUP-A"),
|
||||||
|
_row(fid=2, oms_id=2026100502, supplier_id="SUP-B"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
during_page: list[int] = []
|
||||||
|
during_save: list[int] = []
|
||||||
|
original_claim = repo.claim_pending
|
||||||
|
original_save = repo.save_group
|
||||||
|
original_errors = repo.save_shift_errors
|
||||||
|
|
||||||
|
def claim_pending(limit=None, **kwargs):
|
||||||
|
"""领取时必须占着本班的连接。"""
|
||||||
|
assert repo.held == 1
|
||||||
|
return original_claim(limit=limit, **kwargs)
|
||||||
|
|
||||||
|
def save_group(payload):
|
||||||
|
"""落表时必须占着本班的连接。"""
|
||||||
|
during_save.append(repo.held)
|
||||||
|
return original_save(payload)
|
||||||
|
|
||||||
|
def save_shift_errors(items):
|
||||||
|
"""审计走连接池,不能在 session 里写。"""
|
||||||
|
assert repo.held == 0
|
||||||
|
return original_errors(items)
|
||||||
|
|
||||||
|
def runner(_draft):
|
||||||
|
"""页面执行时连接必须已经归还。"""
|
||||||
|
during_page.append(repo.held)
|
||||||
|
return ContractOutcome(KIND_OK, "HT-REUSE", None)
|
||||||
|
|
||||||
|
repo.claim_pending = claim_pending
|
||||||
|
repo.save_group = save_group
|
||||||
|
repo.save_shift_errors = save_shift_errors
|
||||||
|
summary = run_batch(repo, runner=runner)
|
||||||
|
assert during_page == [0, 0]
|
||||||
|
assert during_save == [1, 1]
|
||||||
|
assert repo.held == 0
|
||||||
|
assert summary.groups == 2
|
||||||
|
assert summary.ok == 2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
# 合同 Oracle 仓库:.env 填充、领取 SQL、落表一条连接。不连真库。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rpa_oracle import RepoUnavailable, load_job_env
|
||||||
|
from persist import KIND_OK, ContractOutcome, build_save
|
||||||
|
from repo import (
|
||||||
|
LEASE_MINUTES,
|
||||||
|
OracleContractRepo,
|
||||||
|
PurchaseContractRow,
|
||||||
|
_SELECT_PENDING,
|
||||||
|
_SELECT_PENDING_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条待录合同的表 3 行。
|
||||||
|
def _row(**overrides) -> PurchaseContractRow:
|
||||||
|
data = dict(
|
||||||
|
fid=1,
|
||||||
|
oms_id=2026100501,
|
||||||
|
supplier_id="SUP001",
|
||||||
|
supplier_name="示例供应商",
|
||||||
|
contract_sign_date=date(2026, 10, 1),
|
||||||
|
contract_start_date=date(2026, 10, 2),
|
||||||
|
contract_end_date=date(2027, 9, 30),
|
||||||
|
payment_term=90,
|
||||||
|
goods_id="G0001",
|
||||||
|
goods_name="阿莫西林胶囊",
|
||||||
|
reg_no="国药准字H20201234",
|
||||||
|
spec="0.25g*24粒",
|
||||||
|
unit="盒",
|
||||||
|
qty=Decimal("10.0000"),
|
||||||
|
unit_price=Decimal("100.0000"),
|
||||||
|
purchaser_id="P001",
|
||||||
|
purchaser_name="张三",
|
||||||
|
planner_id="PL001",
|
||||||
|
planner_name="李四",
|
||||||
|
contract_entry_status=None,
|
||||||
|
contract_no=None,
|
||||||
|
process_status=0,
|
||||||
|
org_code="901",
|
||||||
|
goods_mdm_code="QX0100167583",
|
||||||
|
supplier_mdm_code="DW0100046361",
|
||||||
|
)
|
||||||
|
data.update(overrides)
|
||||||
|
return PurchaseContractRow(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# 合同 .env 空缺时补 oms .env,已有非空环境变量不覆盖。
|
||||||
|
def test_load_runtime_env_fills_blank_from_oms_env(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
||||||
|
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
||||||
|
monkeypatch.setenv("RPA_ORACLE_USER", "keep-me")
|
||||||
|
contract_env = tmp_path / "contract.env"
|
||||||
|
oms_env = tmp_path / "oms.env"
|
||||||
|
contract_env.write_text(
|
||||||
|
"RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\nRPA_ORACLE_USER=from-contract\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
oms_env.write_text(
|
||||||
|
"RPA_ORACLE_PASSWORD=not-for-git\nRPA_ORACLE_USER=from-oms\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
load_job_env(tmp_path, [contract_env, oms_env])
|
||||||
|
assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
|
||||||
|
assert os.environ["RPA_ORACLE_PASSWORD"] == "not-for-git"
|
||||||
|
assert os.environ["RPA_ORACLE_USER"] == "keep-me"
|
||||||
|
|
||||||
|
|
||||||
|
# 缺 HOST/用户/密码时 from_env 抛数据库不可用。
|
||||||
|
def test_from_env_without_user_raises(monkeypatch):
|
||||||
|
monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
|
||||||
|
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
||||||
|
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
||||||
|
with pytest.raises(RepoUnavailable):
|
||||||
|
OracleContractRepo.from_env()
|
||||||
|
|
||||||
|
|
||||||
|
# 录 SQL 的假游标,fetchall 返回预设行。
|
||||||
|
class _FakeCursor:
|
||||||
|
# 挂上共享的 executed 列表和预设查询结果。
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
executed: list,
|
||||||
|
rows: list | None = None,
|
||||||
|
description=None,
|
||||||
|
many: list | None = None,
|
||||||
|
):
|
||||||
|
self._executed = executed
|
||||||
|
self._rows = rows or []
|
||||||
|
self._many = many if many is not None else []
|
||||||
|
self.description = description
|
||||||
|
self.rowcount = 1
|
||||||
|
|
||||||
|
# 进入 with,返回自身。
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
# 退出 with,不吞异常。
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 记下 SQL 和 bind。一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
params = params or {}
|
||||||
|
_reject_oversized_in(params)
|
||||||
|
self._executed.append((sql, params))
|
||||||
|
|
||||||
|
# 记下一次批量 SQL 和全部 bind。
|
||||||
|
def executemany(self, sql, seq):
|
||||||
|
self._many.append((sql, [dict(item) for item in seq]))
|
||||||
|
|
||||||
|
# 返回预设行的副本。
|
||||||
|
def fetchall(self):
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
# 假连接:同一实例上的 cursor 共享 executed 列表。
|
||||||
|
class _FakeConn:
|
||||||
|
# 可选带上领取查询要返回的行。
|
||||||
|
def __init__(self, rows: list | None = None, description=None):
|
||||||
|
self.executed: list[tuple[str, dict]] = []
|
||||||
|
self.many: list[tuple[str, list]] = []
|
||||||
|
self.committed = False
|
||||||
|
self.commits = 0
|
||||||
|
self._rows = rows or []
|
||||||
|
self._description = description
|
||||||
|
|
||||||
|
# 新开一个录 SQL 的游标。
|
||||||
|
def cursor(self):
|
||||||
|
return _FakeCursor(self.executed, self._rows, self._description, many=self.many)
|
||||||
|
|
||||||
|
# 记下已提交。
|
||||||
|
def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
# 本套用例不应回滚。
|
||||||
|
def rollback(self):
|
||||||
|
raise AssertionError("不应 rollback")
|
||||||
|
|
||||||
|
# 归还时由池调用,假连接不计数。
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 只交出一条连接,并记下借了几次。
|
||||||
|
class _FakePool:
|
||||||
|
# 挂上要借出的那条假连接。
|
||||||
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
self.acquires = 0
|
||||||
|
|
||||||
|
# 借出连接并给 acquires 加一。
|
||||||
|
@contextmanager
|
||||||
|
def acquire(self):
|
||||||
|
self.acquires += 1
|
||||||
|
yield self._conn
|
||||||
|
|
||||||
|
|
||||||
|
_CLAIM_DESCRIPTION = (
|
||||||
|
("FID",),
|
||||||
|
("OMS_ID",),
|
||||||
|
("SUPPLIER_ID",),
|
||||||
|
("SUPPLIER_NAME",),
|
||||||
|
("CONTRACT_SIGN_DATE",),
|
||||||
|
("CONTRACT_START_DATE",),
|
||||||
|
("CONTRACT_END_DATE",),
|
||||||
|
("PAYMENT_TERM",),
|
||||||
|
("GOODS_ID",),
|
||||||
|
("GOODS_NAME",),
|
||||||
|
("REG_NO",),
|
||||||
|
("SPEC",),
|
||||||
|
("UNIT",),
|
||||||
|
("QTY",),
|
||||||
|
("UNIT_PRICE",),
|
||||||
|
("PURCHASER_ID",),
|
||||||
|
("PURCHASER_NAME",),
|
||||||
|
("PLANNER_ID",),
|
||||||
|
("PLANNER_NAME",),
|
||||||
|
("CONTRACT_ENTRY_STATUS",),
|
||||||
|
("CONTRACT_NO",),
|
||||||
|
("PROCESS_STATUS",),
|
||||||
|
("ORG_CODE",),
|
||||||
|
("GOODS_MDM_CODE",),
|
||||||
|
("SUPPLIER_MDM_CODE",),
|
||||||
|
("PROCESS_RESULT",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 构造领取查询返回的一行元组。
|
||||||
|
def _fetched_row(fid: int = 11, oms_id: int = 2026100501):
|
||||||
|
return (
|
||||||
|
fid,
|
||||||
|
oms_id,
|
||||||
|
"SUP001",
|
||||||
|
"示例供应商",
|
||||||
|
date(2026, 10, 1),
|
||||||
|
date(2026, 10, 2),
|
||||||
|
date(2027, 9, 30),
|
||||||
|
90,
|
||||||
|
"G0001",
|
||||||
|
"阿莫西林胶囊",
|
||||||
|
"国药准字H20201234",
|
||||||
|
"0.25g*24粒",
|
||||||
|
"盒",
|
||||||
|
Decimal("10.0000"),
|
||||||
|
Decimal("100.0000"),
|
||||||
|
"P001",
|
||||||
|
"张三",
|
||||||
|
"PL001",
|
||||||
|
"李四",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
"901",
|
||||||
|
"QX0100167583",
|
||||||
|
"DW0100046361",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期 process_status=3 可再领。
|
||||||
|
def test_claim_sql_uses_skip_locked_and_lease():
|
||||||
|
sql = " ".join(_SELECT_PENDING.split()).lower()
|
||||||
|
assert "for update skip locked" in sql
|
||||||
|
assert "process_status = 0" in sql
|
||||||
|
assert "process_status = 3" in sql
|
||||||
|
assert "process_time" in sql
|
||||||
|
assert "numtodsinterval(:lease_minutes" in sql
|
||||||
|
assert "skip locked" in " ".join(_SELECT_PENDING_LIMIT.split()).lower()
|
||||||
|
assert LEASE_MINUTES == 30
|
||||||
|
|
||||||
|
|
||||||
|
# 领取一次借连接:SKIP LOCKED 选出后盖成 3。
|
||||||
|
def test_claim_pending_locks_and_stamps_in_one_acquire():
|
||||||
|
conn = _FakeConn(rows=[_fetched_row()], description=_CLAIM_DESCRIPTION)
|
||||||
|
pool = _FakePool(conn)
|
||||||
|
repo = OracleContractRepo(pool)
|
||||||
|
claimed = repo.claim_pending(limit=5)
|
||||||
|
assert pool.acquires == 1
|
||||||
|
assert [row.fid for row in claimed] == [11]
|
||||||
|
assert claimed[0].process_status == 3
|
||||||
|
assert conn.committed is True
|
||||||
|
select_sql, select_binds = conn.executed[0]
|
||||||
|
assert "skip locked" in select_sql.lower()
|
||||||
|
assert select_binds["lease_minutes"] == 30
|
||||||
|
assert select_binds["limit"] == 5
|
||||||
|
update_sql = " ".join(conn.executed[1][0].split()).lower()
|
||||||
|
assert "process_status = 3" in update_sql
|
||||||
|
assert "fid" in update_sql
|
||||||
|
|
||||||
|
|
||||||
|
# 表 4 插入和表 3 更新各一次 executemany,同一连接同一事务。
|
||||||
|
def test_save_group_inserts_then_updates_in_one_acquire():
|
||||||
|
conn = _FakeConn()
|
||||||
|
pool = _FakePool(conn)
|
||||||
|
repo = OracleContractRepo(pool)
|
||||||
|
payload = build_save(
|
||||||
|
[_row(), _row(fid=2, oms_id=2026100502)],
|
||||||
|
ContractOutcome(KIND_OK, "HT1", None),
|
||||||
|
)
|
||||||
|
repo.save_group(payload)
|
||||||
|
assert pool.acquires == 1
|
||||||
|
assert conn.committed is True
|
||||||
|
assert conn.executed == []
|
||||||
|
assert len(conn.many) == 2
|
||||||
|
insert_sql, insert_binds = conn.many[0]
|
||||||
|
update_sql, update_binds = conn.many[1]
|
||||||
|
assert "rpa_contract_agreement_result" in insert_sql.lower()
|
||||||
|
assert [row["oms_id"] for row in insert_binds] == [2026100501, 2026100502]
|
||||||
|
assert "rpa_purchase_contract_data" in update_sql.lower()
|
||||||
|
assert [row["fid"] for row in update_binds] == [1, 2]
|
||||||
|
assert [row["process_status"] for row in update_binds] == [1, 1]
|
||||||
|
|
||||||
|
|
||||||
|
# 班次 session 内领取加落表只从池里借一次连接。
|
||||||
|
def test_session_claim_and_save_acquire_once():
|
||||||
|
conn = _FakeConn(
|
||||||
|
rows=[_fetched_row(11), _fetched_row(12, oms_id=2026100502)],
|
||||||
|
description=_CLAIM_DESCRIPTION,
|
||||||
|
)
|
||||||
|
pool = _FakePool(conn)
|
||||||
|
repo = OracleContractRepo(pool)
|
||||||
|
with repo.session():
|
||||||
|
claimed = repo.claim_pending()
|
||||||
|
assert [row.fid for row in claimed] == [11, 12]
|
||||||
|
repo.save_group(build_save(claimed, ContractOutcome(KIND_OK, "HT1", None)))
|
||||||
|
assert pool.acquires == 1
|
||||||
|
assert conn.commits >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def _fid_bind_count(params: dict) -> int:
|
||||||
|
"""数出本段 bind 里的 fid 个数。"""
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for key in params
|
||||||
|
if isinstance(key, str) and key.startswith("id") and key[2:].isdigit()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_oversized_in(params: dict) -> None:
|
||||||
|
"""一条 IN 超过 1000 个 fid 时抛错,模拟 ORA-01795。"""
|
||||||
|
if _fid_bind_count(params) > 1000:
|
||||||
|
raise RuntimeError("ORA-01795")
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_1001_rows_updates_first_chunk_without_unavailable():
|
||||||
|
"""领取 1001 条时第一段 500 能领走,同一事务提交,不报数据库不可用。"""
|
||||||
|
conn = _FakeConn(
|
||||||
|
rows=[_fetched_row(fid, oms_id=2026100500 + fid) for fid in range(1, 1002)],
|
||||||
|
description=_CLAIM_DESCRIPTION,
|
||||||
|
)
|
||||||
|
pool = _FakePool(conn)
|
||||||
|
repo = OracleContractRepo(pool)
|
||||||
|
claimed = repo.claim_pending()
|
||||||
|
assert pool.acquires == 1
|
||||||
|
assert len(claimed) == 1001
|
||||||
|
assert claimed[0].process_status == 3
|
||||||
|
updates = [
|
||||||
|
item for item in conn.executed if item[0].lstrip().lower().startswith("update")
|
||||||
|
]
|
||||||
|
assert [_fid_bind_count(params) for _, params in updates] == [500, 500, 1]
|
||||||
|
assert updates[0][1]["id0"] == 1
|
||||||
|
assert conn.committed is True
|
||||||
|
|
@ -18,25 +18,31 @@
|
||||||
- 接口 1 落表 `deal_flag=0`,请求里不跑选品
|
- 接口 1 落表 `deal_flag=0`,请求里不跑选品
|
||||||
- 接口 3 落表 `process_status=0`,请求里不录合同
|
- 接口 3 落表 `process_status=0`,请求里不录合同
|
||||||
|
|
||||||
接口 2 出站组装:`load_cart_settlement_envelope(repo)`,同一 `omsId` 不拆批,一批最多 500 条。不改 `push_status`。
|
接口 2 出站组装:`load_cart_settlement_envelope(repo)`,同一 `omsId` 不拆批。上限默认 500,环境变量 `OMS_OUTBOUND_BATCH_LIMIT` 可改。SQL 侧按组截断。不改 `push_status`,不发 HTTP。连接从 `rpa_oracle` 池借。
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from rpa_oracle.pool import create_pool
|
||||||
from app.outbound.cart_settlement import load_cart_settlement_envelope
|
from app.outbound.cart_settlement import load_cart_settlement_envelope
|
||||||
from app.outbound.oracle_repo import OracleCartSettlementRepo
|
from app.outbound.oracle_repo import OracleCartSettlementRepo
|
||||||
|
|
||||||
envelope = load_cart_settlement_envelope(OracleCartSettlementRepo.from_env())
|
pool = create_pool(role="oms")
|
||||||
|
with pool.acquire() as conn:
|
||||||
|
envelope = load_cart_settlement_envelope(OracleCartSettlementRepo(conn))
|
||||||
```
|
```
|
||||||
|
|
||||||
接口 4 出站组装:`load_contract_agreement_envelope(repo)`,同一非空 `contractNo` 不拆批,一批最多 500 条。不改 `push_status`。
|
接口 4 出站组装:`load_contract_agreement_envelope(repo)`,同一非空 `contractNo` 不拆批。上限同样默认 500。不改 `push_status`,不发 HTTP。
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from rpa_oracle.pool import create_pool
|
||||||
from app.outbound.contract_agreement import load_contract_agreement_envelope
|
from app.outbound.contract_agreement import load_contract_agreement_envelope
|
||||||
from app.outbound.oracle_repo import OracleContractAgreementRepo
|
from app.outbound.oracle_repo import OracleContractAgreementRepo
|
||||||
|
|
||||||
envelope = load_contract_agreement_envelope(OracleContractAgreementRepo.from_env())
|
pool = create_pool(role="oms")
|
||||||
|
with pool.acquire() as conn:
|
||||||
|
envelope = load_contract_agreement_envelope(OracleContractAgreementRepo(conn))
|
||||||
```
|
```
|
||||||
|
|
||||||
运行时只落 / 读 Oracle。单测用内存替身,不连库:`uv run pytest -v`。
|
运行时只落 / 读 Oracle。入站单测用假连接录 SQL,不连库。
|
||||||
|
|
||||||
## 真库
|
## 真库
|
||||||
|
|
||||||
|
|
@ -93,3 +99,48 @@ curl.exe -sS http://127.0.0.1:8001/api/oms/v1/purchase-contracts `
|
||||||
-H "X-Oms-Token: dev-oms-token" `
|
-H "X-Oms-Token: dev-oms-token" `
|
||||||
--data-binary "@examples/purchase_contract_batch.json"
|
--data-binary "@examples/purchase_contract_batch.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 运维
|
||||||
|
|
||||||
|
### 怎么跑
|
||||||
|
|
||||||
|
Windows 真库见上一节:本机 `.env` 配 `OMS_INBOUND_TOKEN` 和 `RPA_ORACLE_*`,`.\start.ps1` 起 8001。缺 HOST / 用户 / 密码时收件回 503。审计表 DDL 在 `docs/ddl/rpa_oms_staging_tables.sql` 末尾 `rpa_oms_inbound_audit`,真库要先建这张表。
|
||||||
|
|
||||||
|
### 看表 1/3 哪列
|
||||||
|
|
||||||
|
- 接口 1:`rpa_sales_goods_mapping_data`。看 `oms_id`、`deal_flag`(0 待选品)、`bar_code`、`goods_mdm_code`。同一 `oms_id` 不应有第二行。
|
||||||
|
- 接口 3:`rpa_purchase_contract_data`。看 `fid`、`process_status`(0 待合同班)、`contract_entry_status`、`contract_no`。按对方 `fid` 幂等。
|
||||||
|
- 审计:`rpa_oms_inbound_audit`。看 `api_no`(1 或 3)、`http_code`、`error_text`、`token_masked`。
|
||||||
|
- 接口 2:`rpa_cart_settlement_goods_data`。看 `oms_id`、`push_status`(0 未推,1 成功,2 失败,3 推送中)、`push_result`。
|
||||||
|
- 接口 4:`rpa_contract_agreement_result`。看 `contract_no`、`push_status`、`push_result`。
|
||||||
|
|
||||||
|
### 出站批次和重推
|
||||||
|
|
||||||
|
- 一批上限:环境变量 `OMS_OUTBOUND_BATCH_LIMIT`,默认 500。口 2 同一 `omsId` 不拆批;口 4 同一非空 `contractNo` 不拆批。
|
||||||
|
- 未推:`SELECT * FROM rpa_cart_settlement_goods_data WHERE push_status = 0`(口 4 换表名)。
|
||||||
|
- 已推成功:`push_status = 1`。
|
||||||
|
- 失败重推:把 `push_status` 改回 0(例如 `UPDATE ... SET push_status = 0 WHERE push_status = 2`)。`mark_pushed` 已备,路径仍空,不要把未 POST 的行标成已推。
|
||||||
|
- 缺 HOST / 用户 / 密码时 `create_pool` 抛「数据库不可用」,文案不含环境变量名。
|
||||||
|
|
||||||
|
### 崩溃与审计失败
|
||||||
|
|
||||||
|
入站请求里不跑选品、不录合同,没有班次那种「停在 3」的戳。库故障整包 503,一条业务行都不落。审计 INSERT 失败只记本机日志,不把已经 200 的收件打成 500。
|
||||||
|
|
||||||
|
### 503 对外 vs 审计原文
|
||||||
|
|
||||||
|
对外 `msg` 固定「数据库不可用」,HTTP body 不含 DSN、Instant Client 路径、`ORA-`、表名。ORA 原文在审计表 `error_text`。401 对外「身份认证失败」,token 只存 `***`。
|
||||||
|
|
||||||
|
### 点名命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rpa_oms
|
||||||
|
uv run pytest tests/test_sales_goods_receive.py -q
|
||||||
|
uv run pytest tests/test_oracle_repo.py -q
|
||||||
|
uv run pytest tests/test_purchase_contract_receive.py -q
|
||||||
|
uv run pytest tests/test_oracle_contract_repo.py -q
|
||||||
|
uv run pytest tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q
|
||||||
|
uv run pytest tests/test_oracle_cart_repo.py::test_list_unpushed_keeps_whole_oms_group_when_limit_is_3 -q
|
||||||
|
uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q
|
||||||
|
```
|
||||||
|
|
||||||
|
真库:同一 JSON POST 两次 → 先 ACCEPTED 后 DUPLICATE。再人为断库,接口回固定「数据库不可用」,审计表有原文。
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
"""洁达 RPA × 订单中心入站服务。"""
|
# 洁达 RPA × 订单中心入站服务。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
"""订单中心调本方:请求头 X-Oms-Token,和环境变量 OMS_INBOUND_TOKEN 比对。
|
# 订单中心调本方的共享密钥鉴权。
|
||||||
|
#
|
||||||
|
# 内网一对一:请求头 X-Oms-Token,和环境变量 OMS_INBOUND_TOKEN 做 compare_digest。
|
||||||
|
# 不上 OAuth、不加用户会话。未配置 token 或请求没带 token 一律失败,避免空串把鉴权打成敞开。
|
||||||
|
# 失败由 main._post_inbound 回 401「身份认证失败」并写审计(token 打码)。
|
||||||
|
|
||||||
未配置 token 时一律失败,避免空串把鉴权打成敞开。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
# 比较请求头与环境变量里的共享密钥。两边有空就失败。
|
||||||
def token_ok(provided: str | None) -> bool:
|
def token_ok(provided: str | None) -> bool:
|
||||||
expected = os.getenv("OMS_INBOUND_TOKEN", "")
|
expected = os.getenv("OMS_INBOUND_TOKEN", "")
|
||||||
if not expected or not provided:
|
if not expected or not provided:
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
"""入站收件:拆信封、逐条校验。接口 1 按 omsId 幂等,接口 3 按 fid 幂等。"""
|
# 入站收件:拆信封、整批校验再落库。接口 1 按 omsId 幂等,接口 3 按 fid 幂等。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# 入站审计:一行请求一条,写失败只记日志,不把业务请求打成 500。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("rpa_oms.inbound.audit")
|
||||||
|
|
||||||
|
_INSERT_SQL = """
|
||||||
|
INSERT INTO rpa_oms_inbound_audit (
|
||||||
|
api_no, http_code, request_json, response_json, error_text, token_masked
|
||||||
|
) VALUES (
|
||||||
|
:api_no, :http_code, :request_json, :response_json, :error_text, :token_masked
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ERROR_MAX = 4000
|
||||||
|
|
||||||
|
|
||||||
|
# 有 token 就打成 ***,没有则空。不把明文写进审计。
|
||||||
|
def mask_token(token: str | None) -> str | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return "***"
|
||||||
|
|
||||||
|
|
||||||
|
# 把入参或出参收成 JSON 字符串,datetime / Decimal 用 str。
|
||||||
|
def dumps(payload: Any) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
# 内部错误原文截到 VARCHAR2(4000)。空串当没有。
|
||||||
|
def clip_error(text: str | None) -> str | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if len(text) > _ERROR_MAX:
|
||||||
|
return text[:_ERROR_MAX]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# 往审计表插一行。任何异常都吞掉并记日志。
|
||||||
|
def write_audit(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
api_no: int,
|
||||||
|
http_code: int,
|
||||||
|
payload: Any,
|
||||||
|
response: Any,
|
||||||
|
error_text: str | None,
|
||||||
|
token: str | None,
|
||||||
|
) -> None:
|
||||||
|
binds = {
|
||||||
|
"api_no": api_no,
|
||||||
|
"http_code": http_code,
|
||||||
|
"request_json": dumps(payload if payload is not None else {}),
|
||||||
|
"response_json": dumps(response if response is not None else {}),
|
||||||
|
"error_text": clip_error(error_text),
|
||||||
|
"token_masked": mask_token(token),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(_INSERT_SQL, binds)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("inbound audit write failed")
|
||||||
|
|
||||||
|
|
||||||
|
# 取出内部错误原文:优先 __cause__,供审计表用,不给出站。
|
||||||
|
def cause_text(exc: BaseException) -> str:
|
||||||
|
cause = exc.__cause__ or exc
|
||||||
|
return str(cause)
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
"""批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。
|
# 批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。
|
||||||
|
#
|
||||||
单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。
|
# 单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。
|
||||||
"""
|
|
||||||
|
|
||||||
MAX_BATCH = 100
|
MAX_BATCH = 100
|
||||||
|
|
||||||
|
|
||||||
|
# 信封不合法,对应 HTTP 422。
|
||||||
class EnvelopeError(Exception):
|
class EnvelopeError(Exception):
|
||||||
|
# 记下对外文案。
|
||||||
def __init__(self, message: str):
|
def __init__(self, message: str):
|
||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
# 拆出 data 数组。不合法时抛 EnvelopeError,调用方整包拒绝。
|
||||||
def parse_envelope(payload: object) -> list:
|
def parse_envelope(payload: object) -> list:
|
||||||
if not isinstance(payload, dict) or "data" not in payload:
|
if not isinstance(payload, dict) or "data" not in payload:
|
||||||
raise EnvelopeError("缺少批量数据节点 data")
|
raise EnvelopeError("缺少批量数据节点 data")
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
"""内存仓库。只给单测用,进程退出即丢。运行时走 Oracle。
|
|
||||||
|
|
||||||
按 oms_id 判重,已存在就 DuplicateOmsId,不覆盖。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .repo import DuplicateFid, DuplicateOmsId
|
|
||||||
|
|
||||||
|
|
||||||
class MemorySalesGoodsRepo:
|
|
||||||
def __init__(self):
|
|
||||||
self._rows: dict[int, dict] = {}
|
|
||||||
|
|
||||||
def insert(self, record: dict) -> None:
|
|
||||||
oms_id = record["oms_id"]
|
|
||||||
if oms_id in self._rows:
|
|
||||||
raise DuplicateOmsId(oms_id)
|
|
||||||
self._rows[oms_id] = dict(record)
|
|
||||||
|
|
||||||
def get(self, oms_id: int) -> dict | None:
|
|
||||||
row = self._rows.get(oms_id)
|
|
||||||
return dict(row) if row is not None else None
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
return len(self._rows)
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryPurchaseContractRepo:
|
|
||||||
def __init__(self):
|
|
||||||
self._rows: dict[int, dict] = {}
|
|
||||||
|
|
||||||
def insert(self, record: dict) -> None:
|
|
||||||
fid = record["fid"]
|
|
||||||
if fid in self._rows:
|
|
||||||
raise DuplicateFid(fid)
|
|
||||||
self._rows[fid] = dict(record)
|
|
||||||
|
|
||||||
def get(self, fid: int) -> dict | None:
|
|
||||||
row = self._rows.get(fid)
|
|
||||||
return dict(row) if row is not None else None
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
return len(self._rows)
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 入站对外固定文案。HTTP body 只用这里的句子,不把 ORA 原文带出去。
|
||||||
|
|
||||||
|
AUTH_FAILED = "身份认证失败"
|
||||||
|
DB_UNAVAILABLE = "数据库不可用"
|
||||||
|
|
||||||
|
ROW_NOT_OBJECT = "明细必须是对象"
|
||||||
|
|
||||||
|
SALES_ACCEPTED = "接收成功,等待RPA处理"
|
||||||
|
SALES_DUPLICATE = "订单中心序号已存在,未重复入库"
|
||||||
|
SALES_CONFLICT = "同一请求中 omsId 重复"
|
||||||
|
|
||||||
|
CONTRACT_ACCEPTED = "采购合同数据已接收,等待处理"
|
||||||
|
CONTRACT_DUPLICATE = "序号已存在,未重复入库"
|
||||||
|
CONTRACT_CONFLICT = "同一请求中 fid 重复"
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"""接口 3 Oracle 落表。fid 是订单中心来源序号,INSERT 必须绑这一列。"""
|
# 接口 3 Oracle 落表。fid 是订单中心来源序号,INSERT 必须绑这一列。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from rpa_oracle.errors import RepoUnavailable, is_contract_fid_duplicate
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from .oracle_repo import _ensure_client, _ora_code
|
from .oracle_repo import _rollback, in_binds, unique_collisions
|
||||||
from .repo import DuplicateFid, RepoUnavailable
|
|
||||||
|
|
||||||
_INSERT_SQL = """
|
_INSERT_SQL = """
|
||||||
INSERT INTO rpa_purchase_contract_data (
|
INSERT INTO rpa_purchase_contract_data (
|
||||||
|
|
@ -60,44 +59,51 @@ _BIND_KEYS = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 接口 3 连 Oracle 的仓库,管表 3 按 fid 查重和批量插入。
|
||||||
|
#
|
||||||
|
# 连接由调用方从池里借来,一个请求一条。
|
||||||
class OraclePurchaseContractRepo:
|
class OraclePurchaseContractRepo:
|
||||||
def __init__(self, connect: Callable):
|
# 挂上本请求的连接。不要在仓库里再 connect。
|
||||||
self._connect = connect
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
|
||||||
@classmethod
|
# 查出表 3 里已经有的 fid。空列表不访问库。
|
||||||
def from_env(cls) -> "OraclePurchaseContractRepo":
|
def existing_fids(self, fids: list[int]) -> set[int]:
|
||||||
user = os.getenv("RPA_ORACLE_USER", "")
|
if not fids:
|
||||||
password = os.getenv("RPA_ORACLE_PASSWORD", "")
|
return set()
|
||||||
host = os.getenv("RPA_ORACLE_HOST", "")
|
placeholders, binds = in_binds(fids)
|
||||||
if not user or not password or not host:
|
sql = (
|
||||||
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
|
"SELECT fid FROM rpa_purchase_contract_data "
|
||||||
port = os.getenv("RPA_ORACLE_PORT", "1521")
|
f"WHERE fid IN ({placeholders})"
|
||||||
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
|
)
|
||||||
dsn = f"{host}:{port}/{service}"
|
|
||||||
|
|
||||||
def connect():
|
|
||||||
_ensure_client()
|
|
||||||
import oracledb
|
|
||||||
|
|
||||||
return oracledb.connect(user=user, password=password, dsn=dsn)
|
|
||||||
|
|
||||||
return cls(connect=connect)
|
|
||||||
|
|
||||||
def insert(self, record: dict) -> None:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with self._conn.cursor() as cur:
|
||||||
cur.execute(_INSERT_SQL, {key: record[key] for key in _BIND_KEYS})
|
cur.execute(sql, binds)
|
||||||
conn.commit()
|
return {row[0] for row in cur.fetchall()}
|
||||||
except DuplicateFid:
|
except Exception as exc:
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
# 一次 executemany 插入待写入行。
|
||||||
|
#
|
||||||
|
# 返回撞上 pk_rpa_pcd 的 fid。其它唯一约束或库故障抛 RepoUnavailable。
|
||||||
|
# 成功行在本方法内 commit。
|
||||||
|
def insert_many(self, records: list[dict]) -> set[int]:
|
||||||
|
if not records:
|
||||||
|
return set()
|
||||||
|
payload = [{key: record[key] for key in _BIND_KEYS} for record in records]
|
||||||
|
try:
|
||||||
|
with self._conn.cursor() as cur:
|
||||||
|
cur.executemany(_INSERT_SQL, payload, batcherrors=True)
|
||||||
|
collided = unique_collisions(
|
||||||
|
cur, records, "fid", is_contract_fid_duplicate
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return collided
|
||||||
|
except RepoUnavailable:
|
||||||
|
_rollback(self._conn)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
try:
|
_rollback(self._conn)
|
||||||
conn.rollback()
|
if is_contract_fid_duplicate(exc) and len(records) == 1:
|
||||||
except Exception:
|
return {records[0]["fid"]}
|
||||||
pass
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
if _ora_code(exc) == 1:
|
|
||||||
raise DuplicateFid(record["fid"]) from exc
|
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
"""接口 1 Oracle 落表。
|
# 接口 1 Oracle 落表。
|
||||||
|
#
|
||||||
|
# 11.2 只能 thick(Instant Client)。连接从 rpa_oracle 池借。
|
||||||
|
# fid 由序列触发器生成,INSERT 不绑这一列。
|
||||||
|
# 唯一约束只认 uk_rpa_sgmd_oms_id 为 oms_id 重复;其它唯一约束当系统故障。
|
||||||
|
|
||||||
11.2 只能 thick(Instant Client)。Windows 把 Instant Client 目录设到
|
|
||||||
RPA_ORACLE_CLIENT_LIB。fid 由序列触发器生成,INSERT 不绑这一列。
|
|
||||||
唯一约束冲突(ORA-00001)当成 oms_id 重复。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from rpa_oracle.errors import RepoUnavailable, is_sales_oms_id_duplicate
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from .repo import DuplicateOmsId, RepoUnavailable
|
|
||||||
|
|
||||||
# 接口入站无登录用户:创建人/更新人固定 0 / 系统管理员,时间用库时钟。
|
|
||||||
_INSERT_SQL = """
|
_INSERT_SQL = """
|
||||||
INSERT INTO rpa_sales_goods_mapping_data (
|
INSERT INTO rpa_sales_goods_mapping_data (
|
||||||
oms_id, bar_code, order_date, sale_price, sale_qty, plan_purchase_qty,
|
oms_id, bar_code, order_date, sale_price, sale_qty, plan_purchase_qty,
|
||||||
|
|
@ -50,67 +46,90 @@ _BIND_KEYS = (
|
||||||
"customer_mdm_code",
|
"customer_mdm_code",
|
||||||
)
|
)
|
||||||
|
|
||||||
_client_ready = False
|
|
||||||
|
|
||||||
|
|
||||||
|
# 接口 1 连 Oracle 的仓库,管表 1 查重和批量插入。
|
||||||
|
#
|
||||||
|
# 连接由调用方从池里借来,一个请求一条。
|
||||||
class OracleSalesGoodsRepo:
|
class OracleSalesGoodsRepo:
|
||||||
def __init__(self, connect: Callable):
|
# 挂上本请求的连接。不要在仓库里再 connect。
|
||||||
self._connect = connect
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
|
||||||
@classmethod
|
# 查出表 1 里已经有的 oms_id。空列表不访问库。
|
||||||
def from_env(cls) -> "OracleSalesGoodsRepo":
|
def existing_oms_ids(self, oms_ids: list[int]) -> set[int]:
|
||||||
user = os.getenv("RPA_ORACLE_USER", "")
|
if not oms_ids:
|
||||||
password = os.getenv("RPA_ORACLE_PASSWORD", "")
|
return set()
|
||||||
host = os.getenv("RPA_ORACLE_HOST", "")
|
placeholders, binds = in_binds(oms_ids)
|
||||||
if not user or not password or not host:
|
sql = (
|
||||||
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
|
"SELECT oms_id FROM rpa_sales_goods_mapping_data "
|
||||||
port = os.getenv("RPA_ORACLE_PORT", "1521")
|
f"WHERE oms_id IN ({placeholders})"
|
||||||
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
|
)
|
||||||
dsn = f"{host}:{port}/{service}"
|
|
||||||
|
|
||||||
def connect():
|
|
||||||
_ensure_client()
|
|
||||||
import oracledb
|
|
||||||
|
|
||||||
return oracledb.connect(user=user, password=password, dsn=dsn)
|
|
||||||
|
|
||||||
return cls(connect=connect)
|
|
||||||
|
|
||||||
def insert(self, record: dict) -> None:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with self._conn.cursor() as cur:
|
||||||
cur.execute(_INSERT_SQL, {key: record[key] for key in _BIND_KEYS})
|
cur.execute(sql, binds)
|
||||||
conn.commit()
|
return {row[0] for row in cur.fetchall()}
|
||||||
except DuplicateOmsId:
|
except Exception as exc:
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
# 一次 executemany 插入待写入行。
|
||||||
|
#
|
||||||
|
# 返回撞上 uk_rpa_sgmd_oms_id 的 oms_id。其它唯一约束或库故障抛 RepoUnavailable。
|
||||||
|
# 成功行在本方法内 commit。
|
||||||
|
def insert_many(self, records: list[dict]) -> set[int]:
|
||||||
|
if not records:
|
||||||
|
return set()
|
||||||
|
payload = [{key: record[key] for key in _BIND_KEYS} for record in records]
|
||||||
|
try:
|
||||||
|
with self._conn.cursor() as cur:
|
||||||
|
cur.executemany(_INSERT_SQL, payload, batcherrors=True)
|
||||||
|
collided = unique_collisions(
|
||||||
|
cur, records, "oms_id", is_sales_oms_id_duplicate
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return collided
|
||||||
|
except RepoUnavailable:
|
||||||
|
_rollback(self._conn)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
_rollback(self._conn)
|
||||||
|
if is_sales_oms_id_duplicate(exc) and len(records) == 1:
|
||||||
|
return {records[0]["oms_id"]}
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# 把一组整数编成 :id0, :id1 占位和 bind 字典。
|
||||||
|
def in_binds(ids: list[int]) -> tuple[str, dict[str, int]]:
|
||||||
|
binds = {f"id{i}": value for i, value in enumerate(ids)}
|
||||||
|
placeholders = ", ".join(f":id{i}" for i in range(len(ids)))
|
||||||
|
return placeholders, binds
|
||||||
|
|
||||||
|
|
||||||
|
# 把 getbatcherrors() 单项收成 ora_code / ora_constraint 能认的异常。
|
||||||
|
def as_ora_exc(err) -> Exception:
|
||||||
|
message = getattr(err, "message", str(err))
|
||||||
|
code = getattr(err, "code", None)
|
||||||
|
info = type("Info", (), {"code": code, "message": message})()
|
||||||
|
exc = Exception(message)
|
||||||
|
exc.args = (info,)
|
||||||
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
# 从批错误里挑出幂等键冲突的 id;碰到未知约束则整批失败。
|
||||||
|
def unique_collisions(cur, records: list[dict], key: str, is_dup) -> set[int]:
|
||||||
|
collided: set[int] = set()
|
||||||
|
errors = cur.getbatcherrors() if hasattr(cur, "getbatcherrors") else []
|
||||||
|
for err in errors:
|
||||||
|
wrapped = as_ora_exc(err)
|
||||||
|
if is_dup(wrapped):
|
||||||
|
collided.add(records[err.offset][key])
|
||||||
|
continue
|
||||||
|
raise RepoUnavailable("数据库不可用") from wrapped
|
||||||
|
return collided
|
||||||
|
|
||||||
|
|
||||||
|
# 回滚本请求已写入的行;回滚失败忽略。
|
||||||
|
def _rollback(conn) -> None:
|
||||||
try:
|
try:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if _ora_code(exc) == 1:
|
|
||||||
raise DuplicateOmsId(record["oms_id"]) from exc
|
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _ora_code(exc: BaseException) -> int | None:
|
|
||||||
if not exc.args:
|
|
||||||
return None
|
|
||||||
return getattr(exc.args[0], "code", None)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_client() -> None:
|
|
||||||
# init_oracle_client 每个进程只能调一次。
|
|
||||||
global _client_ready
|
|
||||||
if _client_ready:
|
|
||||||
return
|
|
||||||
import oracledb
|
|
||||||
|
|
||||||
if os.getenv("RPA_ORACLE_THICK", "1") == "1":
|
|
||||||
lib_dir = os.getenv("RPA_ORACLE_CLIENT_LIB", "")
|
|
||||||
kwargs = {"lib_dir": lib_dir} if lib_dir else {}
|
|
||||||
oracledb.init_oracle_client(**kwargs)
|
|
||||||
_client_ready = True
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# 入站字段解析。实现在 app.parse。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..parse import (
|
||||||
|
parse_date,
|
||||||
|
parse_decimal,
|
||||||
|
parse_fid,
|
||||||
|
parse_int,
|
||||||
|
parse_oms_id,
|
||||||
|
parse_positive_int,
|
||||||
|
parse_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"parse_date",
|
||||||
|
"parse_decimal",
|
||||||
|
"parse_fid",
|
||||||
|
"parse_int",
|
||||||
|
"parse_oms_id",
|
||||||
|
"parse_positive_int",
|
||||||
|
"parse_str",
|
||||||
|
]
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
"""接口 3 逐条校验与落表。不跑合同作业。
|
# 接口 3 整批校验与落表。不跑合同作业。
|
||||||
|
#
|
||||||
|
# 入参 camelCase,落表 snake_case。幂等键是对方 fid。
|
||||||
|
# 请求里不录合同。
|
||||||
|
|
||||||
入参 camelCase,落表 snake_case。幂等键是对方 fid。
|
|
||||||
请求里不录合同。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
from . import messages
|
||||||
|
from .parse import parse_date, parse_decimal, parse_fid, parse_int, parse_oms_id, parse_str
|
||||||
from .repo import DuplicateFid
|
|
||||||
from .sales_goods import _parse_decimal, _parse_int, _parse_oms_id, _parse_str
|
|
||||||
|
|
||||||
_STRING_FIELDS = (
|
_STRING_FIELDS = (
|
||||||
("supplierId", "supplier_id", 20),
|
("supplierId", "supplier_id", 20),
|
||||||
|
|
@ -41,21 +39,30 @@ _DECIMAL_FIELDS = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 整批接收采购合同:先校验再落库,返回与入参下标对齐的结果。
|
||||||
def receive_purchase_contracts(rows: list, repo) -> list[dict]:
|
def receive_purchase_contracts(rows: list, repo) -> list[dict]:
|
||||||
|
results: list[dict | None] = [None] * len(rows)
|
||||||
|
pending: list[tuple[int, dict]] = []
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
results: list[dict] = []
|
for index, row in enumerate(rows):
|
||||||
for row in rows:
|
decided = _classify(row, seen)
|
||||||
results.append(_receive_one(row, repo, seen))
|
if "resultCode" in decided:
|
||||||
return results
|
results[index] = decided
|
||||||
|
else:
|
||||||
|
pending.append((index, decided))
|
||||||
|
if pending:
|
||||||
|
_persist(results, pending, repo)
|
||||||
|
return [item for item in results if item is not None]
|
||||||
|
|
||||||
|
|
||||||
def _receive_one(row: object, repo, seen: set[int]) -> dict:
|
# 内存判定:对象/字段/同批 fid 冲突当场出结果,通过则返回待插入记录。
|
||||||
|
def _classify(row: object, seen: set[int]) -> dict:
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
return _result(None, "REJECTED", "明细必须是对象")
|
return _result(None, "REJECTED", messages.ROW_NOT_OBJECT)
|
||||||
|
|
||||||
fid, fid_error = _parse_fid(row.get("fid"))
|
fid, fid_error = parse_fid(row.get("fid"))
|
||||||
if fid is not None and fid in seen:
|
if fid is not None and fid in seen:
|
||||||
return _result(fid, "CONFLICT", "同一请求中 fid 重复")
|
return _result(fid, "CONFLICT", messages.CONTRACT_CONFLICT)
|
||||||
if fid is not None:
|
if fid is not None:
|
||||||
seen.add(fid)
|
seen.add(fid)
|
||||||
|
|
||||||
|
|
@ -63,32 +70,52 @@ def _receive_one(row: object, repo, seen: set[int]) -> dict:
|
||||||
if error:
|
if error:
|
||||||
_camel, message = error
|
_camel, message = error
|
||||||
return _result(row.get("fid"), "REJECTED", message)
|
return _result(row.get("fid"), "REJECTED", message)
|
||||||
|
return parsed
|
||||||
try:
|
|
||||||
repo.insert(parsed)
|
|
||||||
except DuplicateFid:
|
|
||||||
return _result(parsed["fid"], "DUPLICATE", "序号已存在,未重复入库")
|
|
||||||
|
|
||||||
return _result(parsed["fid"], "ACCEPTED", "采购合同数据已接收,等待处理")
|
|
||||||
|
|
||||||
|
|
||||||
|
# 对通过校验的行一次查重、一次插入,把 DUPLICATE / ACCEPTED 填回 results。
|
||||||
|
def _persist(results: list[dict | None], pending: list[tuple[int, dict]], repo) -> None:
|
||||||
|
existing = repo.existing_fids([record["fid"] for _index, record in pending])
|
||||||
|
to_insert: list[dict] = []
|
||||||
|
insert_at: list[int] = []
|
||||||
|
for index, record in pending:
|
||||||
|
if record["fid"] in existing:
|
||||||
|
results[index] = _result(
|
||||||
|
record["fid"], "DUPLICATE", messages.CONTRACT_DUPLICATE
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
to_insert.append(record)
|
||||||
|
insert_at.append(index)
|
||||||
|
collided = repo.insert_many(to_insert) if to_insert else set()
|
||||||
|
for index, record in zip(insert_at, to_insert):
|
||||||
|
if record["fid"] in collided:
|
||||||
|
results[index] = _result(
|
||||||
|
record["fid"], "DUPLICATE", messages.CONTRACT_DUPLICATE
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results[index] = _result(
|
||||||
|
record["fid"], "ACCEPTED", messages.CONTRACT_ACCEPTED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 把一条明细收成表 3 行。失败返回 (None, (字段, 说明))。
|
||||||
def _parse_row(row: dict, fid: int | None, fid_error: str | None):
|
def _parse_row(row: dict, fid: int | None, fid_error: str | None):
|
||||||
if fid_error:
|
if fid_error:
|
||||||
return None, ("fid", fid_error)
|
return None, ("fid", fid_error)
|
||||||
|
|
||||||
oms_id, oms_error = _parse_oms_id(row.get("omsId"))
|
oms_id, oms_error = parse_oms_id(row.get("omsId"))
|
||||||
if oms_error:
|
if oms_error:
|
||||||
return None, ("omsId", oms_error)
|
return None, ("omsId", oms_error)
|
||||||
|
|
||||||
record: dict = {"fid": fid, "oms_id": oms_id, "process_status": 0}
|
record: dict = {"fid": fid, "oms_id": oms_id, "process_status": 0}
|
||||||
for camel, snake, max_len in _STRING_FIELDS:
|
for camel, snake, max_len in _STRING_FIELDS:
|
||||||
value, error = _parse_str(row.get(camel), camel, max_len)
|
value, error = parse_str(row.get(camel), camel, max_len)
|
||||||
if error:
|
if error:
|
||||||
return None, (camel, error)
|
return None, (camel, error)
|
||||||
record[snake] = value
|
record[snake] = value
|
||||||
|
|
||||||
for camel, snake in _DATE_FIELDS:
|
for camel, snake in _DATE_FIELDS:
|
||||||
value, error = _parse_date(row.get(camel), camel)
|
value, error = parse_date(row.get(camel), camel)
|
||||||
if error:
|
if error:
|
||||||
return None, (camel, error)
|
return None, (camel, error)
|
||||||
record[snake] = value
|
record[snake] = value
|
||||||
|
|
@ -96,18 +123,18 @@ def _parse_row(row: dict, fid: int | None, fid_error: str | None):
|
||||||
if record["contract_end_date"] < record["contract_start_date"]:
|
if record["contract_end_date"] < record["contract_start_date"]:
|
||||||
return None, ("contractEndDate", "contractEndDate 不得早于 contractStartDate")
|
return None, ("contractEndDate", "contractEndDate 不得早于 contractStartDate")
|
||||||
|
|
||||||
payment_term, error = _parse_int(row.get("paymentTerm"), "paymentTerm")
|
payment_term, error = parse_int(row.get("paymentTerm"), "paymentTerm")
|
||||||
if error:
|
if error:
|
||||||
return None, ("paymentTerm", error)
|
return None, ("paymentTerm", error)
|
||||||
record["payment_term"] = payment_term
|
record["payment_term"] = payment_term
|
||||||
|
|
||||||
for camel, snake in _DECIMAL_FIELDS:
|
for camel, snake in _DECIMAL_FIELDS:
|
||||||
value, error = _parse_decimal(row.get(camel), camel)
|
value, error = parse_decimal(row.get(camel), camel)
|
||||||
if error:
|
if error:
|
||||||
return None, (camel, error)
|
return None, (camel, error)
|
||||||
record[snake] = value
|
record[snake] = value
|
||||||
|
|
||||||
customer_type, error = _parse_int(row.get("customerType"), "customerType")
|
customer_type, error = parse_int(row.get("customerType"), "customerType")
|
||||||
if error:
|
if error:
|
||||||
return None, ("customerType", error)
|
return None, ("customerType", error)
|
||||||
record["customer_type"] = customer_type
|
record["customer_type"] = customer_type
|
||||||
|
|
@ -125,29 +152,7 @@ def _parse_row(row: dict, fid: int | None, fid_error: str | None):
|
||||||
return record, None
|
return record, None
|
||||||
|
|
||||||
|
|
||||||
def _parse_fid(value) -> tuple[int | None, str | None]:
|
# 录入合同状态:缺省当 None;有值必须是 0 或 1。
|
||||||
if value is None or value == "":
|
|
||||||
return None, "缺少必填字段 fid"
|
|
||||||
if type(value) is int and 0 < value < 10**19:
|
|
||||||
return value, None
|
|
||||||
return None, "fid 必须是整数"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value, camel: str) -> tuple[date | None, str | None]:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None, f"缺少必填字段 {camel}"
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return value.date(), None
|
|
||||||
if isinstance(value, date):
|
|
||||||
return value, None
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None, f"{camel} 必须是 YYYY-MM-DD"
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(value.strip()), None
|
|
||||||
except ValueError:
|
|
||||||
return None, f"{camel} 必须是 YYYY-MM-DD"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_entry_status(value) -> tuple[int | None, str | None]:
|
def _parse_entry_status(value) -> tuple[int | None, str | None]:
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return None, None
|
return None, None
|
||||||
|
|
@ -156,13 +161,15 @@ def _parse_entry_status(value) -> tuple[int | None, str | None]:
|
||||||
return None, "contractEntryStatus 必须是 0 或 1"
|
return None, "contractEntryStatus 必须是 0 或 1"
|
||||||
|
|
||||||
|
|
||||||
|
# contractEntryStatus=0 时合同号必填;其它情况可空。
|
||||||
def _parse_contract_no(value, entry_status: int | None) -> tuple[str | None, str | None]:
|
def _parse_contract_no(value, entry_status: int | None) -> tuple[str | None, str | None]:
|
||||||
if entry_status == 0:
|
if entry_status == 0:
|
||||||
return _parse_str(value, "contractNo", 40)
|
return parse_str(value, "contractNo", 40)
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return None, None
|
return None, None
|
||||||
return _parse_str(value, "contractNo", 40)
|
return parse_str(value, "contractNo", 40)
|
||||||
|
|
||||||
|
|
||||||
|
# 拼一条接口 3 的 results[] 项。
|
||||||
def _result(fid, result_code: str, message: str) -> dict:
|
def _result(fid, result_code: str, message: str) -> dict:
|
||||||
return {"fid": fid, "resultCode": result_code, "message": message}
|
return {"fid": fid, "resultCode": result_code, "message": message}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
"""收件仓库契约。
|
# 收件仓库契约。
|
||||||
|
#
|
||||||
|
# DuplicateOmsId / DuplicateFid:幂等键已存在时的旧异常类型,班次和出站仍可能引用。
|
||||||
|
# RepoUnavailable 与 rpa_oracle.errors 是同一个类:连不上库或未配置,整包 HTTP 503。
|
||||||
|
|
||||||
DuplicateOmsId:oms_id 已存在,调用方回 DUPLICATE,不更新。
|
from rpa_oracle.errors import RepoUnavailable
|
||||||
DuplicateFid:接口 3 来源 fid 已存在,调用方回 DUPLICATE,不更新。
|
|
||||||
RepoUnavailable:连不上库或未配置,整包 HTTP 503,一条都不落。
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
|
# 表 1 已有同一 oms_id。新入站路径用查重集合,不再抛这个。
|
||||||
class DuplicateOmsId(Exception):
|
class DuplicateOmsId(Exception):
|
||||||
|
# 记下冲突的 oms_id。
|
||||||
def __init__(self, oms_id: int):
|
def __init__(self, oms_id: int):
|
||||||
self.oms_id = oms_id
|
self.oms_id = oms_id
|
||||||
|
|
||||||
|
|
||||||
|
# 表 3 已有同一 fid。新入站路径用查重集合,不再抛这个。
|
||||||
class DuplicateFid(Exception):
|
class DuplicateFid(Exception):
|
||||||
|
# 记下冲突的 fid。
|
||||||
def __init__(self, fid: int):
|
def __init__(self, fid: int):
|
||||||
self.fid = fid
|
self.fid = fid
|
||||||
|
|
||||||
|
|
||||||
class RepoUnavailable(Exception):
|
__all__ = ["DuplicateFid", "DuplicateOmsId", "RepoUnavailable"]
|
||||||
def __init__(self, message: str = "数据库不可用"):
|
|
||||||
self.message = message
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,21 @@
|
||||||
"""接口 1 逐条校验与落表。不跑选品。
|
# 接口 1 整批校验与落表。不跑选品。
|
||||||
|
#
|
||||||
|
# 入参 camelCase,落表 snake_case。长度跟 VARCHAR2。
|
||||||
|
#
|
||||||
|
# 先在内存里标 REJECTED / 同批 CONFLICT,再一次查重、一次插入。
|
||||||
|
# 单条结果:
|
||||||
|
# - ACCEPTED:入库,deal_flag=0,等选品作业捞
|
||||||
|
# - DUPLICATE:库里已有同一 omsId,或并发撞上 uk_rpa_sgmd_oms_id
|
||||||
|
# - CONFLICT:同一请求里 omsId 出现过(前一条哪怕被拒,后一条也算冲突)
|
||||||
|
# - REJECTED:字段不过,不入库
|
||||||
|
#
|
||||||
|
# 混批 HTTP 仍 200。omsId / 数量必须是 JSON 整数,不把 "10" 当 10。
|
||||||
|
|
||||||
入参 camelCase,落表 snake_case。长度跟 VARCHAR2。
|
|
||||||
|
|
||||||
单条结果:
|
|
||||||
- ACCEPTED:入库,deal_flag=0,等选品作业捞
|
|
||||||
- DUPLICATE:库里已有同一 omsId,不更新、不重跑
|
|
||||||
- CONFLICT:同一请求里 omsId 出现过(前一条哪怕被拒,后一条也算冲突)
|
|
||||||
- REJECTED:字段不过,不入库
|
|
||||||
|
|
||||||
混批 HTTP 仍 200。omsId / 数量必须是 JSON 整数,不把 "10" 当 10。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
from . import messages
|
||||||
from decimal import Decimal, InvalidOperation
|
from .parse import parse_date, parse_decimal, parse_int, parse_oms_id, parse_str
|
||||||
|
|
||||||
from .repo import DuplicateOmsId
|
|
||||||
|
|
||||||
# (接口字段, 表列, 最大字符数)
|
|
||||||
_STRING_FIELDS = (
|
_STRING_FIELDS = (
|
||||||
("barCode", "bar_code", 40),
|
("barCode", "bar_code", 40),
|
||||||
("goodsId", "goods_id", 20),
|
("goodsId", "goods_id", 20),
|
||||||
|
|
@ -39,21 +37,30 @@ _INT_FIELDS = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 整批接收销售商品:先校验再落库,返回与入参下标对齐的结果。
|
||||||
def receive_sales_goods(rows: list, repo) -> list[dict]:
|
def receive_sales_goods(rows: list, repo) -> list[dict]:
|
||||||
|
results: list[dict | None] = [None] * len(rows)
|
||||||
|
pending: list[tuple[int, dict]] = []
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
results: list[dict] = []
|
for index, row in enumerate(rows):
|
||||||
for row in rows:
|
decided = _classify(row, seen)
|
||||||
results.append(_receive_one(row, repo, seen))
|
if "resultCode" in decided:
|
||||||
return results
|
results[index] = decided
|
||||||
|
else:
|
||||||
|
pending.append((index, decided))
|
||||||
|
if pending:
|
||||||
|
_persist(results, pending, repo)
|
||||||
|
return [item for item in results if item is not None]
|
||||||
|
|
||||||
|
|
||||||
def _receive_one(row: object, repo, seen: set[int]) -> dict:
|
# 内存判定:对象/字段/同批冲突当场出结果,通过则返回待插入记录。
|
||||||
|
def _classify(row: object, seen: set[int]) -> dict:
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
return _result(None, "REJECTED", "明细必须是对象")
|
return _result(None, "REJECTED", messages.ROW_NOT_OBJECT)
|
||||||
|
|
||||||
oms_id, oms_error = _parse_oms_id(row.get("omsId"))
|
oms_id, oms_error = parse_oms_id(row.get("omsId"))
|
||||||
if oms_id is not None and oms_id in seen:
|
if oms_id is not None and oms_id in seen:
|
||||||
return _result(oms_id, "CONFLICT", "同一请求中 omsId 重复")
|
return _result(oms_id, "CONFLICT", messages.SALES_CONFLICT)
|
||||||
if oms_id is not None:
|
if oms_id is not None:
|
||||||
seen.add(oms_id)
|
seen.add(oms_id)
|
||||||
|
|
||||||
|
|
@ -61,38 +68,58 @@ def _receive_one(row: object, repo, seen: set[int]) -> dict:
|
||||||
if error:
|
if error:
|
||||||
_camel, message = error
|
_camel, message = error
|
||||||
return _result(row.get("omsId"), "REJECTED", message)
|
return _result(row.get("omsId"), "REJECTED", message)
|
||||||
|
return parsed
|
||||||
try:
|
|
||||||
repo.insert(parsed)
|
|
||||||
except DuplicateOmsId:
|
|
||||||
return _result(parsed["oms_id"], "DUPLICATE", "订单中心序号已存在,未重复入库")
|
|
||||||
|
|
||||||
return _result(parsed["oms_id"], "ACCEPTED", "接收成功,等待RPA处理")
|
|
||||||
|
|
||||||
|
|
||||||
|
# 对通过校验的行一次查重、一次插入,把 DUPLICATE / ACCEPTED 填回 results。
|
||||||
|
def _persist(results: list[dict | None], pending: list[tuple[int, dict]], repo) -> None:
|
||||||
|
existing = repo.existing_oms_ids([record["oms_id"] for _index, record in pending])
|
||||||
|
to_insert: list[dict] = []
|
||||||
|
insert_at: list[int] = []
|
||||||
|
for index, record in pending:
|
||||||
|
if record["oms_id"] in existing:
|
||||||
|
results[index] = _result(
|
||||||
|
record["oms_id"], "DUPLICATE", messages.SALES_DUPLICATE
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
to_insert.append(record)
|
||||||
|
insert_at.append(index)
|
||||||
|
collided = repo.insert_many(to_insert) if to_insert else set()
|
||||||
|
for index, record in zip(insert_at, to_insert):
|
||||||
|
if record["oms_id"] in collided:
|
||||||
|
results[index] = _result(
|
||||||
|
record["oms_id"], "DUPLICATE", messages.SALES_DUPLICATE
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results[index] = _result(
|
||||||
|
record["oms_id"], "ACCEPTED", messages.SALES_ACCEPTED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 把一条明细收成表 1 行。失败返回 (None, (字段, 说明))。
|
||||||
def _parse_row(row: dict, oms_id: int | None, oms_error: str | None):
|
def _parse_row(row: dict, oms_id: int | None, oms_error: str | None):
|
||||||
if oms_error:
|
if oms_error:
|
||||||
return None, ("omsId", oms_error)
|
return None, ("omsId", oms_error)
|
||||||
|
|
||||||
record: dict = {"oms_id": oms_id, "deal_flag": 0}
|
record: dict = {"oms_id": oms_id, "deal_flag": 0}
|
||||||
for camel, snake, max_len in _STRING_FIELDS:
|
for camel, snake, max_len in _STRING_FIELDS:
|
||||||
value, error = _parse_str(row.get(camel), camel, max_len)
|
value, error = parse_str(row.get(camel), camel, max_len)
|
||||||
if error:
|
if error:
|
||||||
return None, (camel, error)
|
return None, (camel, error)
|
||||||
record[snake] = value
|
record[snake] = value
|
||||||
|
|
||||||
order_date, error = _parse_date(row.get("orderDate"))
|
order_date, error = parse_date(row.get("orderDate"), "orderDate")
|
||||||
if error:
|
if error:
|
||||||
return None, ("orderDate", error)
|
return None, ("orderDate", error)
|
||||||
record["order_date"] = order_date
|
record["order_date"] = order_date
|
||||||
|
|
||||||
sale_price, error = _parse_decimal(row.get("salePrice"), "salePrice")
|
sale_price, error = parse_decimal(row.get("salePrice"), "salePrice")
|
||||||
if error:
|
if error:
|
||||||
return None, ("salePrice", error)
|
return None, ("salePrice", error)
|
||||||
record["sale_price"] = sale_price
|
record["sale_price"] = sale_price
|
||||||
|
|
||||||
for camel, snake in _INT_FIELDS:
|
for camel, snake in _INT_FIELDS:
|
||||||
value, error = _parse_int(row.get(camel), camel)
|
value, error = parse_int(row.get(camel), camel)
|
||||||
if error:
|
if error:
|
||||||
return None, (camel, error)
|
return None, (camel, error)
|
||||||
record[snake] = value
|
record[snake] = value
|
||||||
|
|
@ -100,71 +127,6 @@ def _parse_row(row: dict, oms_id: int | None, oms_error: str | None):
|
||||||
return record, None
|
return record, None
|
||||||
|
|
||||||
|
|
||||||
def _parse_oms_id(value) -> tuple[int | None, str | None]:
|
# 拼一条接口 1 的 results[] 项。
|
||||||
if value is None or value == "":
|
|
||||||
return None, "缺少必填字段 omsId"
|
|
||||||
# bool 是 int 子类,必须用 type(value) is int。oms_id 列 number(20)。
|
|
||||||
if type(value) is int and 0 < value < 10**20:
|
|
||||||
return value, None
|
|
||||||
return None, "omsId 必须是整数"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_str(value, camel: str, max_len: int) -> tuple[str | None, str | None]:
|
|
||||||
if value is None:
|
|
||||||
return None, f"缺少必填字段 {camel}"
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None, f"{camel} 必须是字符串"
|
|
||||||
text = value.strip()
|
|
||||||
if not text:
|
|
||||||
return None, f"缺少必填字段 {camel}"
|
|
||||||
if len(text) > max_len:
|
|
||||||
return None, f"{camel} 超出长度"
|
|
||||||
return text, None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_int(value, camel: str) -> tuple[int | None, str | None]:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None, f"缺少必填字段 {camel}"
|
|
||||||
if type(value) is int:
|
|
||||||
return value, None
|
|
||||||
return None, f"{camel} 必须是整数"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_decimal(value, camel: str) -> tuple[Decimal | None, str | None]:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None, f"缺少必填字段 {camel}"
|
|
||||||
if type(value) is bool:
|
|
||||||
return None, f"{camel} 必须是数字"
|
|
||||||
try:
|
|
||||||
if isinstance(value, Decimal):
|
|
||||||
amount = value
|
|
||||||
elif isinstance(value, str):
|
|
||||||
amount = Decimal(value.strip())
|
|
||||||
elif type(value) is int:
|
|
||||||
amount = Decimal(value)
|
|
||||||
elif isinstance(value, float):
|
|
||||||
amount = Decimal(str(value))
|
|
||||||
else:
|
|
||||||
return None, f"{camel} 必须是数字"
|
|
||||||
except (InvalidOperation, ValueError):
|
|
||||||
return None, f"{camel} 必须是数字"
|
|
||||||
return amount, None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value) -> tuple[date | None, str | None]:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None, "缺少必填字段 orderDate"
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return value.date(), None
|
|
||||||
if isinstance(value, date):
|
|
||||||
return value, None
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None, "orderDate 必须是 YYYY-MM-DD"
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(value.strip()), None
|
|
||||||
except ValueError:
|
|
||||||
return None, "orderDate 必须是 YYYY-MM-DD"
|
|
||||||
|
|
||||||
|
|
||||||
def _result(oms_id, result_code: str, message: str) -> dict:
|
def _result(oms_id, result_code: str, message: str) -> dict:
|
||||||
return {"omsId": oms_id, "resultCode": result_code, "message": message}
|
return {"omsId": oms_id, "resultCode": result_code, "message": message}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"""从 rpa_oms/.env 读本机配置。该文件已 gitignore,不覆盖已有环境变量。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_ENV_PATH = Path(__file__).resolve().parents[1] / ".env"
|
|
||||||
|
|
||||||
|
|
||||||
def load_local_env(path: Path | None = None) -> None:
|
|
||||||
env_path = path if path is not None else _ENV_PATH
|
|
||||||
if not env_path.is_file():
|
|
||||||
return
|
|
||||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
|
||||||
line = raw.strip()
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip()
|
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
||||||
value = value[1:-1]
|
|
||||||
if key and key not in os.environ:
|
|
||||||
os.environ[key] = value
|
|
||||||
|
|
@ -1,102 +1,165 @@
|
||||||
"""订单中心接口服务。本期开放接口 1、3 收件;接口 2 只在 outbound 组装报文。
|
# 订单中心接口服务。本期开放接口 1、3 收件;接口 2 只在 outbound 组装报文。
|
||||||
|
#
|
||||||
|
# 路径:
|
||||||
|
# - POST /api/oms/v1/sales-goods
|
||||||
|
# - POST /api/oms/v1/purchase-contracts
|
||||||
|
# 鉴权:X-Oms-Token,内网共享密钥,不上 OAuth。
|
||||||
|
# 请求里不跑选品、不录合同。
|
||||||
|
# 对外 401/503 用固定文案;ORA 原文只进审计表。
|
||||||
|
|
||||||
路径:
|
|
||||||
- POST /api/oms/v1/sales-goods
|
|
||||||
- POST /api/oms/v1/purchase-contracts
|
|
||||||
鉴权:X-Oms-Token
|
|
||||||
请求里不跑选品、不录合同。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Header
|
from fastapi import Depends, FastAPI, Header, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from .auth import token_ok
|
# 把仓库根目录放进 sys.path,才能 import rpa_oracle。
|
||||||
from .local_env import load_local_env
|
def _put_repo_on_path() -> None:
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
|
||||||
load_local_env()
|
|
||||||
|
_put_repo_on_path()
|
||||||
|
from rpa_oracle.env import load_env
|
||||||
|
from rpa_oracle.errors import RepoUnavailable
|
||||||
|
|
||||||
|
from .auth import token_ok
|
||||||
|
from .inbound.audit import cause_text, write_audit
|
||||||
from .inbound.envelope import EnvelopeError, parse_envelope
|
from .inbound.envelope import EnvelopeError, parse_envelope
|
||||||
|
from .inbound.messages import AUTH_FAILED, DB_UNAVAILABLE
|
||||||
|
from .inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
||||||
|
from .inbound.oracle_repo import OracleSalesGoodsRepo
|
||||||
from .inbound.purchase_contract import receive_purchase_contracts
|
from .inbound.purchase_contract import receive_purchase_contracts
|
||||||
from .inbound.repo import RepoUnavailable
|
|
||||||
from .inbound.sales_goods import receive_sales_goods
|
from .inbound.sales_goods import receive_sales_goods
|
||||||
|
|
||||||
|
load_env(Path(__file__).resolve().parents[1] / ".env")
|
||||||
|
|
||||||
|
logger = logging.getLogger("rpa_oms")
|
||||||
app = FastAPI(title="洁达 RPA × 订单中心", version="0.1")
|
app = FastAPI(title="洁达 RPA × 订单中心", version="0.1")
|
||||||
|
|
||||||
|
_pool = None
|
||||||
def get_repo():
|
_pool_lock = threading.Lock()
|
||||||
# 运行时只走 Oracle。单测用 dependency_overrides 注入内存替身。
|
|
||||||
from .inbound.oracle_repo import OracleSalesGoodsRepo
|
|
||||||
|
|
||||||
return OracleSalesGoodsRepo.from_env()
|
|
||||||
|
|
||||||
|
|
||||||
def get_contract_repo():
|
# 进程内共用 OMS 连接池。缺配置抛「数据库不可用」。
|
||||||
from .inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
#
|
||||||
|
# 懒加载加锁:并发的首个请求只建一个池,输家的池不会被丢掉漏连接。
|
||||||
|
def get_pool():
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
return _pool
|
||||||
|
with _pool_lock:
|
||||||
|
if _pool is None:
|
||||||
|
from rpa_oracle.pool import create_pool
|
||||||
|
|
||||||
return OraclePurchaseContractRepo.from_env()
|
_pool = create_pool(role="oms")
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
# 依赖注入阶段库不可用时,仍回固定 503,不把异常原文带出去。
|
||||||
|
@app.exception_handler(RepoUnavailable)
|
||||||
|
def handle_repo_unavailable(_request: Request, _exc: RepoUnavailable):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={"code": 503, "msg": DB_UNAVAILABLE, "results": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", description="进程存活探活,不连库。")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/oms/v1/sales-goods")
|
@app.post("/api/oms/v1/sales-goods", description="接收接口 1 销售商品,落表 1。请求里不跑选品。")
|
||||||
def post_sales_goods(
|
def post_sales_goods(
|
||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
x_oms_token: Annotated[str | None, Header()] = None,
|
x_oms_token: Annotated[str | None, Header()] = None,
|
||||||
repo=Depends(get_repo),
|
pool=Depends(get_pool),
|
||||||
):
|
):
|
||||||
return _post_inbound(
|
return _post_inbound(
|
||||||
payload,
|
payload,
|
||||||
x_oms_token,
|
x_oms_token,
|
||||||
receive=lambda rows: receive_sales_goods(rows, repo),
|
pool,
|
||||||
|
api_no=1,
|
||||||
|
receive=receive_sales_goods,
|
||||||
|
repo_cls=OracleSalesGoodsRepo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/oms/v1/purchase-contracts")
|
@app.post("/api/oms/v1/purchase-contracts", description="接收接口 3 采购合同,落表 3。请求里不录合同。")
|
||||||
def post_purchase_contracts(
|
def post_purchase_contracts(
|
||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
x_oms_token: Annotated[str | None, Header()] = None,
|
x_oms_token: Annotated[str | None, Header()] = None,
|
||||||
repo=Depends(get_contract_repo),
|
pool=Depends(get_pool),
|
||||||
):
|
):
|
||||||
return _post_inbound(
|
return _post_inbound(
|
||||||
payload,
|
payload,
|
||||||
x_oms_token,
|
x_oms_token,
|
||||||
receive=lambda rows: receive_purchase_contracts(rows, repo),
|
pool,
|
||||||
|
api_no=3,
|
||||||
|
receive=receive_purchase_contracts,
|
||||||
|
repo_cls=OraclePurchaseContractRepo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _post_inbound(payload, x_oms_token, receive):
|
# 接口 1/3 共用:鉴权、拆信封、整批收件、写审计。混批 HTTP 仍 200。
|
||||||
|
def _post_inbound(payload, x_oms_token, pool, api_no: int, receive, repo_cls):
|
||||||
if not token_ok(x_oms_token):
|
if not token_ok(x_oms_token):
|
||||||
return JSONResponse(
|
body = {"code": 401, "msg": AUTH_FAILED, "results": []}
|
||||||
status_code=401,
|
_audit_best_effort(pool, api_no, 401, payload, body, None, x_oms_token)
|
||||||
content={"code": 401, "msg": "身份认证失败", "results": []},
|
return JSONResponse(status_code=401, content=body)
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
rows = parse_envelope(payload if payload is not None else {})
|
rows = parse_envelope(payload if payload is not None else {})
|
||||||
except EnvelopeError as exc:
|
except EnvelopeError as exc:
|
||||||
return JSONResponse(
|
body = {"code": 422, "msg": exc.message, "results": []}
|
||||||
status_code=422,
|
_audit_best_effort(pool, api_no, 422, payload, body, None, x_oms_token)
|
||||||
content={"code": 422, "msg": exc.message, "results": []},
|
return JSONResponse(status_code=422, content=body)
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
results = receive(rows)
|
with pool.acquire() as conn:
|
||||||
except RepoUnavailable as exc:
|
results = receive(rows, repo_cls(conn))
|
||||||
return JSONResponse(
|
body = {"code": 200, "msg": _summary(results), "results": results}
|
||||||
status_code=503,
|
write_audit(
|
||||||
content={"code": 503, "msg": exc.message, "results": []},
|
conn,
|
||||||
|
api_no=api_no,
|
||||||
|
http_code=200,
|
||||||
|
payload=payload,
|
||||||
|
response=body,
|
||||||
|
error_text=None,
|
||||||
|
token=x_oms_token,
|
||||||
)
|
)
|
||||||
# 逐条有成功/重复/拒绝时 HTTP 仍 200,看 results[].resultCode。
|
return body
|
||||||
return {
|
except RepoUnavailable as exc:
|
||||||
"code": 200,
|
body = {"code": 503, "msg": DB_UNAVAILABLE, "results": []}
|
||||||
"msg": _summary(results),
|
_audit_best_effort(
|
||||||
"results": results,
|
pool, api_no, 503, payload, body, cause_text(exc), x_oms_token
|
||||||
}
|
)
|
||||||
|
return JSONResponse(status_code=503, content=body)
|
||||||
|
|
||||||
|
|
||||||
|
# 401/422/503 路径再借一条连接写审计。失败只记日志。
|
||||||
|
def _audit_best_effort(pool, api_no, http_code, payload, body, error_text, token) -> None:
|
||||||
|
try:
|
||||||
|
with pool.acquire() as conn:
|
||||||
|
write_audit(
|
||||||
|
conn,
|
||||||
|
api_no=api_no,
|
||||||
|
http_code=http_code,
|
||||||
|
payload=payload,
|
||||||
|
response=body,
|
||||||
|
error_text=error_text,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("inbound audit write failed")
|
||||||
|
|
||||||
|
|
||||||
|
# 按本批 resultCode 拼整包 msg:全成功 / 含重复 / 其它混批。
|
||||||
def _summary(results: list[dict]) -> str:
|
def _summary(results: list[dict]) -> str:
|
||||||
codes = {item["resultCode"] for item in results}
|
codes = {item["resultCode"] for item in results}
|
||||||
if codes <= {"ACCEPTED"}:
|
if codes <= {"ACCEPTED"}:
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
"""出站:从表组装订单中心报文。本期接口 2、4 只取数组装,不发 HTTP。"""
|
# 出站:从表组装订单中心报文。本期接口 2、4 只取数组装,不发 HTTP。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,36 @@
|
||||||
"""接口 2:从表 2 取未推行,组装文档报文。
|
# 接口 2:从表 2 取未推行,组装文档报文。
|
||||||
|
#
|
||||||
|
# 同一 omsId 的加购行必须在同一批 data 里。一批上限默认 500,可用
|
||||||
|
# OMS_OUTBOUND_BATCH_LIMIT 覆盖。不改 push_status,不向订单中心发 HTTP。
|
||||||
|
|
||||||
同一 omsId 的加购行必须在同一批 data 里。一批最多 500 条。
|
|
||||||
不改 push_status,不向订单中心发 HTTP(路径仍空)。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
|
||||||
|
|
||||||
MAX_BATCH = 500
|
from ..parse import as_int, money4
|
||||||
|
from .limits import MAX_BATCH, batch_limit
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_BATCH",
|
||||||
|
"batch_limit",
|
||||||
|
"build_cart_settlement_envelope",
|
||||||
|
"load_cart_settlement_envelope",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def load_cart_settlement_envelope(repo, limit: int = MAX_BATCH) -> dict:
|
# 从仓库取未推行并组装信封。未传 limit 时读环境变量。
|
||||||
return build_cart_settlement_envelope(repo.list_unpushed(), limit=limit)
|
def load_cart_settlement_envelope(repo, limit: int | None = None) -> dict:
|
||||||
|
cap = batch_limit() if limit is None else limit
|
||||||
|
return build_cart_settlement_envelope(repo.list_unpushed(limit=cap), limit=cap)
|
||||||
|
|
||||||
|
|
||||||
|
# 把已取出的行按 omsId 整组截到 limit,再转文档字段。
|
||||||
def build_cart_settlement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict:
|
def build_cart_settlement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict:
|
||||||
chosen = _take_complete_groups(rows, limit)
|
chosen = _take_complete_groups(rows, limit)
|
||||||
return {"data": [_to_item(row) for row in chosen]}
|
return {"data": [_to_item(row) for row in chosen]}
|
||||||
|
|
||||||
|
|
||||||
|
# 按 oms_id 保序成组;装不下的整组留下。空批时超限组仍进入。
|
||||||
def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
||||||
groups: OrderedDict = OrderedDict()
|
groups: OrderedDict = OrderedDict()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|
@ -32,31 +43,28 @@ def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
|
||||||
|
# 金额四位小数字符串。库里的 NULL 保持 null,不让一行空金额掀掉整批。
|
||||||
|
def _money4(value) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return money4(value)
|
||||||
|
|
||||||
|
|
||||||
|
# 把表 2 行转成接口 2 文档字段。金额四位小数字符串。
|
||||||
def _to_item(row: dict) -> dict:
|
def _to_item(row: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"fid": _as_int(row["fid"]),
|
"fid": as_int(row["fid"]),
|
||||||
"operateType": _as_int(row["operate_type"]),
|
"operateType": as_int(row["operate_type"]),
|
||||||
"omsId": _as_int(row["oms_id"]),
|
"omsId": as_int(row["oms_id"]),
|
||||||
"platformType": _as_int(row["platform_type"]),
|
"platformType": as_int(row["platform_type"]),
|
||||||
"supplierName": row["supplier_name"],
|
"supplierName": row["supplier_name"],
|
||||||
"barCode": row["bar_code"],
|
"barCode": row["bar_code"],
|
||||||
"purchasePrice": _money4(row["purchase_price"]),
|
"purchasePrice": _money4(row.get("purchase_price")),
|
||||||
"purchaseQty": _as_int(row["purchase_qty"]),
|
"purchaseQty": as_int(row["purchase_qty"]),
|
||||||
"purchaseTotal": _money4(row["purchase_total"]),
|
"purchaseTotal": _money4(row.get("purchase_total")),
|
||||||
"status": _as_int(row["status"]) if row.get("status") is not None else None,
|
"status": as_int(row["status"]) if row.get("status") is not None else None,
|
||||||
"failReason": row.get("fail_reason"),
|
"failReason": row.get("fail_reason"),
|
||||||
"payOrderNo": row.get("pay_order_no"),
|
"payOrderNo": row.get("pay_order_no"),
|
||||||
"payOrderDtlId": row.get("pay_order_dtl_id"),
|
"payOrderDtlId": row.get("pay_order_dtl_id"),
|
||||||
"orgCode": row.get("org_code"),
|
"orgCode": row.get("org_code"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _as_int(value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return int(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _money4(value) -> str:
|
|
||||||
amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
|
||||||
return f"{amount:.4f}"
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,36 @@
|
||||||
"""接口 4:从表 4 取未推行,组装文档报文。
|
# 接口 4:从表 4 取未推行,组装文档报文。
|
||||||
|
#
|
||||||
|
# 同一非空 contractNo 必须在同一批 data 里。空 contractNo(失败行)按行独立。
|
||||||
|
# 一批上限默认 500,可用 OMS_OUTBOUND_BATCH_LIMIT 覆盖。不改 push_status,不发 HTTP。
|
||||||
|
|
||||||
同一非空 contractNo 必须在同一批 data 里。一批最多 500 条。
|
|
||||||
空 contractNo(失败行)按行独立。不改 push_status,不发 HTTP。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
MAX_BATCH = 500
|
from ..parse import as_int, as_str
|
||||||
|
from .limits import MAX_BATCH, batch_limit
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_BATCH",
|
||||||
|
"batch_limit",
|
||||||
|
"build_contract_agreement_envelope",
|
||||||
|
"load_contract_agreement_envelope",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def load_contract_agreement_envelope(repo, limit: int = MAX_BATCH) -> dict:
|
# 从仓库取未推行并组装信封。未传 limit 时读环境变量。
|
||||||
return build_contract_agreement_envelope(repo.list_unpushed(), limit=limit)
|
def load_contract_agreement_envelope(repo, limit: int | None = None) -> dict:
|
||||||
|
cap = batch_limit() if limit is None else limit
|
||||||
|
return build_contract_agreement_envelope(repo.list_unpushed(limit=cap), limit=cap)
|
||||||
|
|
||||||
|
|
||||||
|
# 把已取出的行按合同号整组截到 limit,再转文档字段。
|
||||||
def build_contract_agreement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict:
|
def build_contract_agreement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict:
|
||||||
chosen = _take_complete_groups(rows, limit)
|
chosen = _take_complete_groups(rows, limit)
|
||||||
return {"data": [_to_item(row) for row in chosen]}
|
return {"data": [_to_item(row) for row in chosen]}
|
||||||
|
|
||||||
|
|
||||||
|
# 非空合同号成一组;空合同号用 fid 自成一组。
|
||||||
def _group_key(row: dict):
|
def _group_key(row: dict):
|
||||||
number = row.get("contract_no")
|
number = row.get("contract_no")
|
||||||
if number is None or str(number).strip() == "":
|
if number is None or str(number).strip() == "":
|
||||||
|
|
@ -26,6 +38,7 @@ def _group_key(row: dict):
|
||||||
return ("no", str(number).strip())
|
return ("no", str(number).strip())
|
||||||
|
|
||||||
|
|
||||||
|
# 按合同号保序成组;装不下的整组留下。空批时超限组仍进入。
|
||||||
def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
||||||
groups: OrderedDict = OrderedDict()
|
groups: OrderedDict = OrderedDict()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|
@ -38,32 +51,18 @@ def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
|
||||||
|
# 把表 4 行转成接口 4 文档字段。
|
||||||
def _to_item(row: dict) -> dict:
|
def _to_item(row: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"fid": _as_int(row["fid"]),
|
"fid": as_int(row["fid"]),
|
||||||
"omsId": _as_int(row["oms_id"]),
|
"omsId": as_int(row["oms_id"]),
|
||||||
"actionCode": _as_int(row["action_code"]),
|
"actionCode": as_int(row["action_code"]),
|
||||||
"contractStatus": _as_int(row.get("contract_status")),
|
"contractStatus": as_int(row.get("contract_status")),
|
||||||
"contractFailReason": _as_str(row.get("contract_fail_reason")),
|
"contractFailReason": as_str(row.get("contract_fail_reason")),
|
||||||
"contractNo": _as_str(row.get("contract_no")),
|
"contractNo": as_str(row.get("contract_no")),
|
||||||
"agreementStatus": _as_int(row.get("agreement_status")),
|
"agreementStatus": as_int(row.get("agreement_status")),
|
||||||
"agreementFailReason": _as_str(row.get("agreement_fail_reason")),
|
"agreementFailReason": as_str(row.get("agreement_fail_reason")),
|
||||||
"agreementId": _as_str(row.get("agreement_id")),
|
"agreementId": as_str(row.get("agreement_id")),
|
||||||
"agreementDtlId": _as_str(row.get("agreement_dtl_id")),
|
"agreementDtlId": as_str(row.get("agreement_dtl_id")),
|
||||||
"orgCode": _as_str(row.get("org_code")),
|
"orgCode": as_str(row.get("org_code")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _as_int(value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return int(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _as_str(value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
text = str(value)
|
|
||||||
if text.strip() == "":
|
|
||||||
return None
|
|
||||||
return text
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 出站一批上限。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
MAX_BATCH = 500
|
||||||
|
LIMIT_ENV = "OMS_OUTBOUND_BATCH_LIMIT"
|
||||||
|
|
||||||
|
|
||||||
|
# 读 OMS_OUTBOUND_BATCH_LIMIT。缺省、非整数或非正数时用 500。
|
||||||
|
def batch_limit() -> int:
|
||||||
|
raw = os.getenv(LIMIT_ENV, "").strip()
|
||||||
|
if not raw:
|
||||||
|
return MAX_BATCH
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return MAX_BATCH
|
||||||
|
if value <= 0:
|
||||||
|
return MAX_BATCH
|
||||||
|
return value
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
"""内存表 2 / 表 4。只给单测用。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryCartSettlementRepo:
|
|
||||||
def __init__(self, rows: list[dict] | None = None):
|
|
||||||
self._rows = [dict(row) for row in (rows or [])]
|
|
||||||
|
|
||||||
def list_unpushed(self) -> list[dict]:
|
|
||||||
return [dict(row) for row in self._rows if row.get("push_status", 0) == 0]
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryContractAgreementRepo:
|
|
||||||
def __init__(self, rows: list[dict] | None = None):
|
|
||||||
self._rows = [dict(row) for row in (rows or [])]
|
|
||||||
|
|
||||||
def list_unpushed(self) -> list[dict]:
|
|
||||||
return [dict(row) for row in self._rows if row.get("push_status", 0) == 0]
|
|
||||||
|
|
@ -1,101 +1,171 @@
|
||||||
"""接口 2 / 4:读 push_status=0。不更新推送状态。"""
|
# 接口 2 / 4:读 push_status=0,按组在 SQL 侧截断。
|
||||||
|
#
|
||||||
|
# 连接由调用方从 rpa_oracle 池借入,仓库不 connect、不 close。
|
||||||
|
# 不向订单中心发 HTTP。mark_pushed 备好回写,本段不接推送。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from rpa_oracle.errors import RepoUnavailable
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from ..inbound.oracle_repo import _ensure_client
|
from ..inbound.oracle_repo import in_binds
|
||||||
from ..inbound.repo import RepoUnavailable
|
|
||||||
|
|
||||||
_LIST_UNPUSHED_SQL = """
|
_LIST_UNPUSHED_SQL = """
|
||||||
SELECT
|
WITH src AS (
|
||||||
|
SELECT
|
||||||
fid, operate_type, oms_id, platform_type, supplier_name, bar_code,
|
fid, operate_type, oms_id, platform_type, supplier_name, bar_code,
|
||||||
purchase_price, purchase_qty, purchase_total, status, fail_reason,
|
purchase_price, purchase_qty, purchase_total, status, fail_reason,
|
||||||
pay_order_no, pay_order_dtl_id, org_code, push_status
|
pay_order_no, pay_order_dtl_id, org_code, push_status
|
||||||
FROM rpa_cart_settlement_goods_data
|
FROM rpa_cart_settlement_goods_data
|
||||||
WHERE push_status = 0
|
WHERE push_status = 0
|
||||||
ORDER BY oms_id, fid
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
src.*,
|
||||||
|
COUNT(*) OVER (PARTITION BY oms_id) AS grp_size
|
||||||
|
FROM src
|
||||||
|
),
|
||||||
|
grp AS (
|
||||||
|
SELECT oms_id, MIN(grp_size) AS grp_size
|
||||||
|
FROM ranked
|
||||||
|
GROUP BY oms_id
|
||||||
|
),
|
||||||
|
run AS (
|
||||||
|
SELECT
|
||||||
|
oms_id,
|
||||||
|
grp_size,
|
||||||
|
SUM(grp_size) OVER (ORDER BY oms_id) AS running_end,
|
||||||
|
SUM(grp_size) OVER (ORDER BY oms_id) - grp_size AS running_start
|
||||||
|
FROM grp
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.fid, r.operate_type, r.oms_id, r.platform_type, r.supplier_name, r.bar_code,
|
||||||
|
r.purchase_price, r.purchase_qty, r.purchase_total, r.status, r.fail_reason,
|
||||||
|
r.pay_order_no, r.pay_order_dtl_id, r.org_code, r.push_status
|
||||||
|
FROM ranked r
|
||||||
|
JOIN run ON run.oms_id = r.oms_id
|
||||||
|
WHERE run.running_start = 0 OR run.running_end <= :limit
|
||||||
|
ORDER BY r.oms_id, r.fid
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class OracleCartSettlementRepo:
|
|
||||||
def __init__(self, connect: Callable):
|
|
||||||
self._connect = connect
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_env(cls) -> "OracleCartSettlementRepo":
|
|
||||||
user = os.getenv("RPA_ORACLE_USER", "")
|
|
||||||
password = os.getenv("RPA_ORACLE_PASSWORD", "")
|
|
||||||
host = os.getenv("RPA_ORACLE_HOST", "")
|
|
||||||
if not user or not password or not host:
|
|
||||||
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
|
|
||||||
port = os.getenv("RPA_ORACLE_PORT", "1521")
|
|
||||||
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
|
|
||||||
dsn = f"{host}:{port}/{service}"
|
|
||||||
|
|
||||||
def connect():
|
|
||||||
_ensure_client()
|
|
||||||
import oracledb
|
|
||||||
|
|
||||||
return oracledb.connect(user=user, password=password, dsn=dsn)
|
|
||||||
|
|
||||||
return cls(connect=connect)
|
|
||||||
|
|
||||||
def list_unpushed(self) -> list[dict]:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(_LIST_UNPUSHED_SQL)
|
|
||||||
cols = [item[0].lower() for item in cur.description]
|
|
||||||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
||||||
except Exception as exc:
|
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
_LIST_AGREEMENT_SQL = """
|
_LIST_AGREEMENT_SQL = """
|
||||||
SELECT
|
WITH src AS (
|
||||||
|
SELECT
|
||||||
fid, oms_id, action_code, contract_status, contract_fail_reason,
|
fid, oms_id, action_code, contract_status, contract_fail_reason,
|
||||||
contract_no, agreement_status, agreement_fail_reason,
|
contract_no, agreement_status, agreement_fail_reason,
|
||||||
agreement_id, agreement_dtl_id, org_code, push_status
|
agreement_id, agreement_dtl_id, org_code, push_status,
|
||||||
FROM rpa_contract_agreement_result
|
CASE
|
||||||
WHERE push_status = 0
|
WHEN TRIM(contract_no) IS NULL THEN 'F:' || TO_CHAR(fid)
|
||||||
ORDER BY fid
|
ELSE 'N:' || TRIM(contract_no)
|
||||||
|
END AS grp_key
|
||||||
|
FROM rpa_contract_agreement_result
|
||||||
|
WHERE push_status = 0
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
src.*,
|
||||||
|
COUNT(*) OVER (PARTITION BY grp_key) AS grp_size
|
||||||
|
FROM src
|
||||||
|
),
|
||||||
|
grp AS (
|
||||||
|
SELECT grp_key, MIN(grp_size) AS grp_size, MIN(fid) AS grp_first_fid
|
||||||
|
FROM ranked
|
||||||
|
GROUP BY grp_key
|
||||||
|
),
|
||||||
|
run AS (
|
||||||
|
SELECT
|
||||||
|
grp_key,
|
||||||
|
grp_size,
|
||||||
|
SUM(grp_size) OVER (ORDER BY grp_first_fid, grp_key) AS running_end,
|
||||||
|
SUM(grp_size) OVER (ORDER BY grp_first_fid, grp_key) - grp_size AS running_start
|
||||||
|
FROM grp
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.fid, r.oms_id, r.action_code, r.contract_status, r.contract_fail_reason,
|
||||||
|
r.contract_no, r.agreement_status, r.agreement_fail_reason,
|
||||||
|
r.agreement_id, r.agreement_dtl_id, r.org_code, r.push_status
|
||||||
|
FROM ranked r
|
||||||
|
JOIN run ON run.grp_key = r.grp_key
|
||||||
|
WHERE run.running_start = 0 OR run.running_end <= :limit
|
||||||
|
ORDER BY r.fid
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_CART_TABLE = "rpa_cart_settlement_goods_data"
|
||||||
|
_AGREEMENT_TABLE = "rpa_contract_agreement_result"
|
||||||
|
|
||||||
|
|
||||||
|
# 接口 2 连 Oracle 的仓库,管表 2 未推取数和回写。
|
||||||
|
class OracleCartSettlementRepo:
|
||||||
|
# 挂上调用方借来的连接。不要在仓库里再 connect 或 close。
|
||||||
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
|
||||||
|
# 捞 push_status=0,同一 oms_id 不拆组,在 SQL 侧按 limit 截断。
|
||||||
|
def list_unpushed(self, limit: int = 500) -> list[dict]:
|
||||||
|
return _fetch(self._conn, _LIST_UNPUSHED_SQL, {"limit": limit})
|
||||||
|
|
||||||
|
# 一次 UPDATE 回写表 2 的 push_status。空列表不访问库。
|
||||||
|
def mark_pushed(self, fids: list[int], status: int, reason: str | None) -> None:
|
||||||
|
_mark(self._conn, _CART_TABLE, fids, status, reason, "SYSDATE")
|
||||||
|
|
||||||
|
|
||||||
|
# 接口 4 连 Oracle 的仓库,管表 4 未推取数和回写。
|
||||||
class OracleContractAgreementRepo:
|
class OracleContractAgreementRepo:
|
||||||
def __init__(self, connect: Callable):
|
# 挂上调用方借来的连接。不要在仓库里再 connect 或 close。
|
||||||
self._connect = connect
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
|
||||||
@classmethod
|
# 捞 push_status=0,同一非空 contract_no 不拆组,在 SQL 侧按 limit 截断。
|
||||||
def from_env(cls) -> "OracleContractAgreementRepo":
|
def list_unpushed(self, limit: int = 500) -> list[dict]:
|
||||||
user = os.getenv("RPA_ORACLE_USER", "")
|
return _fetch(self._conn, _LIST_AGREEMENT_SQL, {"limit": limit})
|
||||||
password = os.getenv("RPA_ORACLE_PASSWORD", "")
|
|
||||||
host = os.getenv("RPA_ORACLE_HOST", "")
|
|
||||||
if not user or not password or not host:
|
|
||||||
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
|
|
||||||
port = os.getenv("RPA_ORACLE_PORT", "1521")
|
|
||||||
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
|
|
||||||
dsn = f"{host}:{port}/{service}"
|
|
||||||
|
|
||||||
def connect():
|
# 一次 UPDATE 回写表 4 的 push_status。空列表不访问库。
|
||||||
_ensure_client()
|
def mark_pushed(self, fids: list[int], status: int, reason: str | None) -> None:
|
||||||
import oracledb
|
_mark(self._conn, _AGREEMENT_TABLE, fids, status, reason, "SYSTIMESTAMP")
|
||||||
|
|
||||||
return oracledb.connect(user=user, password=password, dsn=dsn)
|
|
||||||
|
|
||||||
return cls(connect=connect)
|
# 执行查询并把列名转小写。失败抛「数据库不可用」,不含 ORA。
|
||||||
|
def _fetch(conn, sql: str, binds: dict) -> list[dict]:
|
||||||
def list_unpushed(self) -> list[dict]:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(_LIST_AGREEMENT_SQL)
|
cur.execute(sql, binds)
|
||||||
cols = [item[0].lower() for item in cur.description]
|
cols = [item[0].lower() for item in cur.description]
|
||||||
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RepoUnavailable(str(exc)) from exc
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
# 一次 UPDATE ... WHERE fid IN (...)。失败回滚并抛「数据库不可用」。
|
||||||
|
def _mark(
|
||||||
|
conn,
|
||||||
|
table: str,
|
||||||
|
fids: list[int],
|
||||||
|
status: int,
|
||||||
|
reason: str | None,
|
||||||
|
time_expr: str,
|
||||||
|
) -> None:
|
||||||
|
if not fids:
|
||||||
|
return
|
||||||
|
placeholders, binds = in_binds(fids)
|
||||||
|
binds["status"] = status
|
||||||
|
binds["reason"] = reason
|
||||||
|
sql = (
|
||||||
|
f"UPDATE {table} "
|
||||||
|
f"SET push_status = :status, push_result = :reason, "
|
||||||
|
f"push_time = {time_expr}, update_time = {time_expr} "
|
||||||
|
f"WHERE fid IN ({placeholders})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql, binds)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
_rollback(conn)
|
||||||
|
raise RepoUnavailable("数据库不可用") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# 回滚本连接已写入的行;回滚失败忽略。
|
||||||
|
def _rollback(conn) -> None:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
# 入站和出站共用的字段解析。
|
||||||
|
#
|
||||||
|
# 入站把 camelCase JSON 收成落表值。缺字段、类型不对或超长时返回
|
||||||
|
# (None, 错误说明),调用方据此标 REJECTED。omsId / fid / 数量必须是 JSON
|
||||||
|
# 整数,不把 "10" 当 10。
|
||||||
|
#
|
||||||
|
# 出站读库行,用 as_int / as_str / money4 收成文档字段。金额是四位小数字符串。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
|
||||||
|
|
||||||
|
|
||||||
|
# 把接口字段收成去掉首尾空白的字符串,并检查是否超过 max_len。
|
||||||
|
#
|
||||||
|
# required=False 时,缺省或空白返回 (None, None)。
|
||||||
|
def parse_str(
|
||||||
|
value, field: str, max_len: int, *, required: bool = True
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
if required:
|
||||||
|
return None, f"缺少必填字段 {field}"
|
||||||
|
return None, None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None, f"{field} 必须是字符串"
|
||||||
|
text = value.strip()
|
||||||
|
if len(text) > max_len:
|
||||||
|
return None, f"{field} 超出长度"
|
||||||
|
return text, None
|
||||||
|
|
||||||
|
|
||||||
|
# 把接口字段收成 JSON 整数。布尔值不算整数。
|
||||||
|
def parse_int(value, field: str, *, required: bool = True) -> tuple[int | None, str | None]:
|
||||||
|
if value is None or value == "":
|
||||||
|
if required:
|
||||||
|
return None, f"缺少必填字段 {field}"
|
||||||
|
return None, None
|
||||||
|
if type(value) is int:
|
||||||
|
return value, None
|
||||||
|
return None, f"{field} 必须是整数"
|
||||||
|
|
||||||
|
|
||||||
|
# 把接口字段收成开区间 (0, max_exclusive) 内的 JSON 整数。
|
||||||
|
def parse_positive_int(
|
||||||
|
value, field: str, max_exclusive: int
|
||||||
|
) -> tuple[int | None, str | None]:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None, f"缺少必填字段 {field}"
|
||||||
|
if type(value) is int and 0 < value < max_exclusive:
|
||||||
|
return value, None
|
||||||
|
return None, f"{field} 必须是整数"
|
||||||
|
|
||||||
|
|
||||||
|
# 把 omsId 收成正整数,上限跟表 1 NUMBER(20)。
|
||||||
|
def parse_oms_id(value) -> tuple[int | None, str | None]:
|
||||||
|
return parse_positive_int(value, "omsId", 10**20)
|
||||||
|
|
||||||
|
|
||||||
|
# 把接口 3 的 fid 收成正整数,上限跟表 3 NUMBER(19)。
|
||||||
|
def parse_fid(value) -> tuple[int | None, str | None]:
|
||||||
|
return parse_positive_int(value, "fid", 10**19)
|
||||||
|
|
||||||
|
|
||||||
|
# 把接口字段收成 Decimal。布尔值拒绝;字符串、int、float 可转。
|
||||||
|
def parse_decimal(value, field: str) -> tuple[Decimal | None, str | None]:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None, f"缺少必填字段 {field}"
|
||||||
|
if type(value) is bool:
|
||||||
|
return None, f"{field} 必须是数字"
|
||||||
|
try:
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
amount = value
|
||||||
|
elif isinstance(value, str):
|
||||||
|
amount = Decimal(value.strip())
|
||||||
|
elif type(value) is int:
|
||||||
|
amount = Decimal(value)
|
||||||
|
elif isinstance(value, float):
|
||||||
|
amount = Decimal(str(value))
|
||||||
|
else:
|
||||||
|
return None, f"{field} 必须是数字"
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None, f"{field} 必须是数字"
|
||||||
|
return amount, None
|
||||||
|
|
||||||
|
|
||||||
|
# 把接口字段收成 date。接受 YYYY-MM-DD 字符串、date、datetime。
|
||||||
|
def parse_date(value, field: str) -> tuple[date | None, str | None]:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None, f"缺少必填字段 {field}"
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date(), None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value, None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None, f"{field} 必须是 YYYY-MM-DD"
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value.strip()), None
|
||||||
|
except ValueError:
|
||||||
|
return None, f"{field} 必须是 YYYY-MM-DD"
|
||||||
|
|
||||||
|
|
||||||
|
# 出站整数。None 保持 None。JSON 整数走 parse_int,库里的 Decimal 交给 int。
|
||||||
|
def as_int(value) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
parsed, error = parse_int(value, "value", required=False)
|
||||||
|
if error is None and parsed is not None:
|
||||||
|
return parsed
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
# 出站字符串。None 和空白当成 None,其余转 str,不改非空原文。
|
||||||
|
def as_str(value) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value)
|
||||||
|
if text.strip() == "":
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# 金额四舍五入到四位小数,再格式成字符串。
|
||||||
|
def money4(value) -> str:
|
||||||
|
amount, error = parse_decimal(value, "amount")
|
||||||
|
if error is not None or amount is None:
|
||||||
|
raise ValueError(error or "缺少金额")
|
||||||
|
quantized = amount.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||||
|
return f"{quantized:.4f}"
|
||||||
|
|
@ -20,7 +20,7 @@ dev = [
|
||||||
package = false
|
package = false
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = [".", ".."]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-q"
|
addopts = "-q"
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
# 入站单测用的假连接:录 SQL、按 oms_id / fid 存行,不连真库。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
# 构造带 oracledb 风格 args[0].code / .message 的假异常。
|
||||||
|
def ora_error(code: int, message: str) -> Exception:
|
||||||
|
info = SimpleNamespace(code=code, message=message)
|
||||||
|
exc = Exception(message)
|
||||||
|
exc.args = (info,)
|
||||||
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
# 构造指定约束名的 ORA-00001。
|
||||||
|
def unique_error(constraint: str) -> Exception:
|
||||||
|
return ora_error(
|
||||||
|
1,
|
||||||
|
f"ORA-00001: unique constraint (RPAINIT.{constraint}) violated",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# oracledb cursor.getbatcherrors() 单项的替身。
|
||||||
|
class BatchError:
|
||||||
|
# 记下失败行在本批里的下标、报文和 ORA 码。
|
||||||
|
def __init__(self, offset: int, message: str, code: int = 1):
|
||||||
|
self.offset = offset
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
_CART_COLS = (
|
||||||
|
"fid",
|
||||||
|
"operate_type",
|
||||||
|
"oms_id",
|
||||||
|
"platform_type",
|
||||||
|
"supplier_name",
|
||||||
|
"bar_code",
|
||||||
|
"purchase_price",
|
||||||
|
"purchase_qty",
|
||||||
|
"purchase_total",
|
||||||
|
"status",
|
||||||
|
"fail_reason",
|
||||||
|
"pay_order_no",
|
||||||
|
"pay_order_dtl_id",
|
||||||
|
"org_code",
|
||||||
|
"push_status",
|
||||||
|
)
|
||||||
|
|
||||||
|
_AGREEMENT_COLS = (
|
||||||
|
"fid",
|
||||||
|
"oms_id",
|
||||||
|
"action_code",
|
||||||
|
"contract_status",
|
||||||
|
"contract_fail_reason",
|
||||||
|
"contract_no",
|
||||||
|
"agreement_status",
|
||||||
|
"agreement_fail_reason",
|
||||||
|
"agreement_id",
|
||||||
|
"agreement_dtl_id",
|
||||||
|
"org_code",
|
||||||
|
"push_status",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 一块假库:销售商品、采购合同、入站审计、表 2 / 表 4,外加 SQL 记录。
|
||||||
|
class FakeDb:
|
||||||
|
# 空库,默认不注入故障。
|
||||||
|
def __init__(self):
|
||||||
|
self.sales: dict[int, dict] = {}
|
||||||
|
self.contracts: dict[int, dict] = {}
|
||||||
|
self.carts: dict[int, dict] = {}
|
||||||
|
self.agreements: dict[int, dict] = {}
|
||||||
|
self.audits: list[dict] = []
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
self.commits = 0
|
||||||
|
self.rollbacks = 0
|
||||||
|
self.closes = 0
|
||||||
|
self.collide_sales: set[int] = set()
|
||||||
|
self.collide_contracts: set[int] = set()
|
||||||
|
self.collide_constraint: str | None = None
|
||||||
|
self.raise_execute: BaseException | None = None
|
||||||
|
|
||||||
|
# 新开一条假连接,读写同一块数据。
|
||||||
|
def connection(self) -> "FakeConn":
|
||||||
|
return FakeConn(self)
|
||||||
|
|
||||||
|
|
||||||
|
# 假 Oracle 连接:cursor / commit / rollback / close。
|
||||||
|
class FakeConn:
|
||||||
|
# 挂到同一块 FakeDb 上。
|
||||||
|
def __init__(self, db: FakeDb):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
# 打开一条假游标。
|
||||||
|
def cursor(self) -> "FakeCursor":
|
||||||
|
return FakeCursor(self.db)
|
||||||
|
|
||||||
|
# 记下提交次数。
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.db.commits += 1
|
||||||
|
|
||||||
|
# 记下回滚次数。
|
||||||
|
def rollback(self) -> None:
|
||||||
|
self.db.rollbacks += 1
|
||||||
|
|
||||||
|
# 记下归还次数。
|
||||||
|
def close(self) -> None:
|
||||||
|
self.db.closes += 1
|
||||||
|
|
||||||
|
|
||||||
|
# 按 SQL 关键字把 SELECT / INSERT / executemany 落到 FakeDb。
|
||||||
|
class FakeCursor:
|
||||||
|
# 空结果、无批错误。
|
||||||
|
def __init__(self, db: FakeDb):
|
||||||
|
self.db = db
|
||||||
|
self._rows: list[tuple] = []
|
||||||
|
self._batch_errors: list[BatchError] = []
|
||||||
|
self.description: tuple = ()
|
||||||
|
|
||||||
|
# with 语句返回自身。
|
||||||
|
def __enter__(self) -> "FakeCursor":
|
||||||
|
return self
|
||||||
|
|
||||||
|
# 不吞异常。
|
||||||
|
def __exit__(self, *args) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 执行单条 SQL:查重、插入、审计,或表 2 / 表 4 取数回写。
|
||||||
|
def execute(self, sql: str, params=None) -> None:
|
||||||
|
self.db.calls.append(("execute", sql, params))
|
||||||
|
if self.db.raise_execute is not None:
|
||||||
|
raise self.db.raise_execute
|
||||||
|
self._dispatch(sql, params, offset=None, batcherrors=False)
|
||||||
|
|
||||||
|
# 按行插入;batcherrors=True 时把唯一冲突记进批错误。
|
||||||
|
def executemany(self, sql: str, seq_of_params, batcherrors: bool = False) -> None:
|
||||||
|
self.db.calls.append(("executemany", sql, seq_of_params, batcherrors))
|
||||||
|
if self.db.raise_execute is not None:
|
||||||
|
raise self.db.raise_execute
|
||||||
|
self._batch_errors = []
|
||||||
|
for offset, params in enumerate(seq_of_params):
|
||||||
|
try:
|
||||||
|
self._dispatch(sql, params, offset=offset, batcherrors=batcherrors)
|
||||||
|
except Exception as exc:
|
||||||
|
if batcherrors and _is_unique(exc):
|
||||||
|
self._batch_errors.append(
|
||||||
|
BatchError(offset, str(exc), code=1)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 返回最近一次 SELECT 的行。
|
||||||
|
def fetchall(self) -> list[tuple]:
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
# 返回最近一次 executemany 的批错误。
|
||||||
|
def getbatcherrors(self) -> list[BatchError]:
|
||||||
|
return list(self._batch_errors)
|
||||||
|
|
||||||
|
# 按表名把 SQL 分到销售、合同、审计、表 2 或表 4。
|
||||||
|
def _dispatch(self, sql: str, params, offset, batcherrors: bool) -> None:
|
||||||
|
norm = " ".join(sql.split()).upper()
|
||||||
|
binds = params or {}
|
||||||
|
if norm.startswith("SELECT") and "RPA_SALES_GOODS_MAPPING_DATA" in norm:
|
||||||
|
ids = list(binds.values()) if isinstance(binds, dict) else list(binds)
|
||||||
|
self._rows = [(oms_id,) for oms_id in ids if oms_id in self.db.sales]
|
||||||
|
return
|
||||||
|
if norm.startswith("SELECT") and "RPA_PURCHASE_CONTRACT_DATA" in norm:
|
||||||
|
ids = list(binds.values()) if isinstance(binds, dict) else list(binds)
|
||||||
|
self._rows = [(fid,) for fid in ids if fid in self.db.contracts]
|
||||||
|
return
|
||||||
|
if "RPA_OMS_INBOUND_AUDIT" in norm:
|
||||||
|
self.db.audits.append(dict(binds))
|
||||||
|
return
|
||||||
|
if "RPA_CART_SETTLEMENT_GOODS_DATA" in norm:
|
||||||
|
if "UPDATE" in norm:
|
||||||
|
self._update_pushed(self.db.carts, binds)
|
||||||
|
return
|
||||||
|
self._select_cart(sql, binds)
|
||||||
|
return
|
||||||
|
if "RPA_CONTRACT_AGREEMENT_RESULT" in norm:
|
||||||
|
if "UPDATE" in norm:
|
||||||
|
self._update_pushed(self.db.agreements, binds)
|
||||||
|
return
|
||||||
|
self._select_agreement(sql, binds)
|
||||||
|
return
|
||||||
|
if "RPA_SALES_GOODS_MAPPING_DATA" in norm:
|
||||||
|
self._insert_sales(binds)
|
||||||
|
return
|
||||||
|
if "RPA_PURCHASE_CONTRACT_DATA" in norm:
|
||||||
|
self._insert_contract(binds)
|
||||||
|
return
|
||||||
|
raise AssertionError(f"unexpected sql: {sql}")
|
||||||
|
|
||||||
|
# 捞表 2 push_status=0;SQL 带 PARTITION BY 且绑了 limit 时按 oms_id 整组截断。
|
||||||
|
def _select_cart(self, sql: str, binds: dict) -> None:
|
||||||
|
rows = [dict(row) for row in self.db.carts.values() if row.get("push_status", 0) == 0]
|
||||||
|
rows.sort(key=lambda row: (row["oms_id"], row["fid"]))
|
||||||
|
if "PARTITION BY" in sql.upper() and "limit" in binds:
|
||||||
|
from app.outbound.cart_settlement import _take_complete_groups
|
||||||
|
|
||||||
|
rows = _take_complete_groups(rows, int(binds["limit"]))
|
||||||
|
self.description = tuple((name.upper(),) for name in _CART_COLS)
|
||||||
|
self._rows = [tuple(row.get(name) for name in _CART_COLS) for row in rows]
|
||||||
|
|
||||||
|
# 捞表 4 push_status=0;SQL 带 PARTITION BY 且绑了 limit 时按合同号整组截断。
|
||||||
|
def _select_agreement(self, sql: str, binds: dict) -> None:
|
||||||
|
rows = [
|
||||||
|
dict(row) for row in self.db.agreements.values() if row.get("push_status", 0) == 0
|
||||||
|
]
|
||||||
|
rows.sort(key=lambda row: row["fid"])
|
||||||
|
if "PARTITION BY" in sql.upper() and "limit" in binds:
|
||||||
|
from app.outbound.contract_agreement import _take_complete_groups
|
||||||
|
|
||||||
|
rows = _take_complete_groups(rows, int(binds["limit"]))
|
||||||
|
self.description = tuple((name.upper(),) for name in _AGREEMENT_COLS)
|
||||||
|
self._rows = [tuple(row.get(name) for name in _AGREEMENT_COLS) for row in rows]
|
||||||
|
|
||||||
|
# 按 :id* 回写 push_status / push_result。
|
||||||
|
def _update_pushed(self, table: dict[int, dict], binds: dict) -> None:
|
||||||
|
status = binds.get("status")
|
||||||
|
reason = binds.get("reason")
|
||||||
|
fids = [value for key, value in binds.items() if str(key).startswith("id")]
|
||||||
|
for fid in fids:
|
||||||
|
row = table.get(fid)
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
row["push_status"] = status
|
||||||
|
row["push_result"] = reason
|
||||||
|
|
||||||
|
# 按 oms_id 写入表 1 替身,冲突时抛 ORA-00001。
|
||||||
|
def _insert_sales(self, params: dict) -> None:
|
||||||
|
oms_id = params["oms_id"]
|
||||||
|
constraint = self.db.collide_constraint
|
||||||
|
if constraint and (oms_id in self.db.collide_sales or oms_id in self.db.sales):
|
||||||
|
raise unique_error(constraint)
|
||||||
|
if oms_id in self.db.sales or oms_id in self.db.collide_sales:
|
||||||
|
raise unique_error("UK_RPA_SGMD_OMS_ID")
|
||||||
|
row = dict(params)
|
||||||
|
row.setdefault("deal_flag", 0)
|
||||||
|
self.db.sales[oms_id] = row
|
||||||
|
|
||||||
|
# 按 fid 写入表 3 替身,冲突时抛 ORA-00001。
|
||||||
|
def _insert_contract(self, params: dict) -> None:
|
||||||
|
fid = params["fid"]
|
||||||
|
constraint = self.db.collide_constraint
|
||||||
|
if constraint and (fid in self.db.collide_contracts or fid in self.db.contracts):
|
||||||
|
raise unique_error(constraint)
|
||||||
|
if fid in self.db.contracts or fid in self.db.collide_contracts:
|
||||||
|
raise unique_error("PK_RPA_PCD")
|
||||||
|
row = dict(params)
|
||||||
|
row.setdefault("process_status", 0)
|
||||||
|
self.db.contracts[fid] = row
|
||||||
|
|
||||||
|
|
||||||
|
# 假异常是否为 ORA-00001。
|
||||||
|
def _is_unique(exc: BaseException) -> bool:
|
||||||
|
if not exc.args:
|
||||||
|
return False
|
||||||
|
info = exc.args[0]
|
||||||
|
return getattr(info, "code", None) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# get_pool 替身:每次 acquire 都借同一块 FakeDb 上的新连接。
|
||||||
|
class FakePool:
|
||||||
|
# 默认自带一块空库。
|
||||||
|
def __init__(self, db: FakeDb | None = None):
|
||||||
|
self.db = db or FakeDb()
|
||||||
|
self.acquires = 0
|
||||||
|
|
||||||
|
# 借一条假连接,退出 with 时 close。
|
||||||
|
@contextmanager
|
||||||
|
def acquire(self):
|
||||||
|
self.acquires += 1
|
||||||
|
conn = FakeConn(self.db)
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
"""接口 2:从表 2 取 push_status=0,组装文档报文。不连 Oracle,不发 HTTP。"""
|
# 接口 2:从表 2 取 push_status=0,组装文档报文。假连接录 SQL,不连真库,不发 HTTP。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from app.outbound.cart_settlement import (
|
from app.outbound.cart_settlement import (
|
||||||
MAX_BATCH,
|
MAX_BATCH,
|
||||||
|
batch_limit,
|
||||||
build_cart_settlement_envelope,
|
build_cart_settlement_envelope,
|
||||||
load_cart_settlement_envelope,
|
load_cart_settlement_envelope,
|
||||||
)
|
)
|
||||||
from app.outbound.memory_repo import MemoryCartSettlementRepo
|
from app.outbound.oracle_repo import OracleCartSettlementRepo
|
||||||
|
from tests.fake_oracle import FakeDb
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条表 2 行,供组装层单测用。
|
||||||
def _row(**overrides):
|
def _row(**overrides):
|
||||||
row = {
|
row = {
|
||||||
"fid": 100001,
|
"fid": 100001,
|
||||||
|
|
@ -34,8 +38,25 @@ def _row(**overrides):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 把表 2 行放进假库。list_unpushed 走真 SQL,调用记在 db.calls。
|
||||||
|
def _open(rows: list[dict]) -> tuple[OracleCartSettlementRepo, FakeDb]:
|
||||||
|
db = FakeDb()
|
||||||
|
for row in rows:
|
||||||
|
db.carts[row["fid"]] = row
|
||||||
|
return OracleCartSettlementRepo(db.connection()), db
|
||||||
|
|
||||||
|
|
||||||
|
# 取出最近一次 execute 的 SQL 和绑定。
|
||||||
|
def _last_execute(db: FakeDb) -> tuple[str, dict]:
|
||||||
|
execute_calls = [call for call in db.calls if call[0] == "execute"]
|
||||||
|
assert execute_calls
|
||||||
|
_, sql, params = execute_calls[-1]
|
||||||
|
return sql, params or {}
|
||||||
|
|
||||||
|
|
||||||
|
# 未推行按文档信封字段组装,金额四位小数字符串。
|
||||||
def test_assembles_unpushed_rows_as_document_envelope():
|
def test_assembles_unpushed_rows_as_document_envelope():
|
||||||
repo = MemoryCartSettlementRepo(
|
repo, db = _open(
|
||||||
[
|
[
|
||||||
_row(),
|
_row(),
|
||||||
_row(
|
_row(
|
||||||
|
|
@ -85,10 +106,39 @@ def test_assembles_unpushed_rows_as_document_envelope():
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
sql, params = _last_execute(db)
|
||||||
|
assert "rpa_cart_settlement_goods_data" in sql.lower()
|
||||||
|
assert "push_status = 0" in sql.lower()
|
||||||
|
assert params["limit"] == 500
|
||||||
|
|
||||||
|
|
||||||
|
# 失败行金额为 NULL 时该行置空,同批其它行照常组装。
|
||||||
|
def test_null_amounts_do_not_fail_the_batch():
|
||||||
|
repo, _db = _open(
|
||||||
|
[
|
||||||
|
_row(
|
||||||
|
fid=1,
|
||||||
|
status=2,
|
||||||
|
purchase_price=None,
|
||||||
|
purchase_total=None,
|
||||||
|
fail_reason="库存不足",
|
||||||
|
),
|
||||||
|
_row(fid=2, oms_id=20260928000002),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
envelope = load_cart_settlement_envelope(repo)
|
||||||
|
failed, ok = envelope["data"]
|
||||||
|
assert failed["status"] == 2
|
||||||
|
assert failed["purchasePrice"] is None
|
||||||
|
assert failed["purchaseTotal"] is None
|
||||||
|
assert failed["failReason"] == "库存不足"
|
||||||
|
assert ok["purchasePrice"] == "128.5000"
|
||||||
|
assert ok["purchaseTotal"] == "1285.0000"
|
||||||
|
|
||||||
|
|
||||||
|
# push_status=1 的行不进报文。
|
||||||
def test_skips_already_pushed_rows():
|
def test_skips_already_pushed_rows():
|
||||||
repo = MemoryCartSettlementRepo(
|
repo, db = _open(
|
||||||
[
|
[
|
||||||
_row(fid=1, push_status=1),
|
_row(fid=1, push_status=1),
|
||||||
_row(fid=2, oms_id=20260928000002, push_status=0),
|
_row(fid=2, oms_id=20260928000002, push_status=0),
|
||||||
|
|
@ -96,8 +146,11 @@ def test_skips_already_pushed_rows():
|
||||||
)
|
)
|
||||||
envelope = load_cart_settlement_envelope(repo)
|
envelope = load_cart_settlement_envelope(repo)
|
||||||
assert [item["fid"] for item in envelope["data"]] == [2]
|
assert [item["fid"] for item in envelope["data"]] == [2]
|
||||||
|
sql, _params = _last_execute(db)
|
||||||
|
assert "push_status = 0" in sql.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# 同一 omsId 的行必须落在同一批 data。
|
||||||
def test_keeps_same_oms_id_in_one_envelope():
|
def test_keeps_same_oms_id_in_one_envelope():
|
||||||
rows = [
|
rows = [
|
||||||
_row(fid=1, oms_id=1, platform_type=48, multi_platform_flag=1),
|
_row(fid=1, oms_id=1, platform_type=48, multi_platform_flag=1),
|
||||||
|
|
@ -109,6 +162,7 @@ def test_keeps_same_oms_id_in_one_envelope():
|
||||||
assert fids == [1, 2]
|
assert fids == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
# 批次已有行时,装不下的 omsId 整组留下。
|
||||||
def test_does_not_split_oms_id_group_across_batch_limit():
|
def test_does_not_split_oms_id_group_across_batch_limit():
|
||||||
rows = [
|
rows = [
|
||||||
_row(fid=1, oms_id=1, platform_type=48),
|
_row(fid=1, oms_id=1, platform_type=48),
|
||||||
|
|
@ -119,6 +173,7 @@ def test_does_not_split_oms_id_group_across_batch_limit():
|
||||||
assert [item["fid"] for item in envelope["data"]] == [1]
|
assert [item["fid"] for item in envelope["data"]] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# 默认上限 500;超过则按组截到 500。
|
||||||
def test_batch_cap_is_500():
|
def test_batch_cap_is_500():
|
||||||
assert MAX_BATCH == 500
|
assert MAX_BATCH == 500
|
||||||
rows = [_row(fid=i, oms_id=i) for i in range(1, 502)]
|
rows = [_row(fid=i, oms_id=i) for i in range(1, 502)]
|
||||||
|
|
@ -128,6 +183,7 @@ def test_batch_cap_is_500():
|
||||||
assert envelope["data"][-1]["fid"] == 500
|
assert envelope["data"][-1]["fid"] == 500
|
||||||
|
|
||||||
|
|
||||||
|
# 报文不含 push_status 等表内列。
|
||||||
def test_payload_omits_table_only_columns():
|
def test_payload_omits_table_only_columns():
|
||||||
envelope = build_cart_settlement_envelope([_row()])
|
envelope = build_cart_settlement_envelope([_row()])
|
||||||
item = envelope["data"][0]
|
item = envelope["data"][0]
|
||||||
|
|
@ -135,3 +191,47 @@ def test_payload_omits_table_only_columns():
|
||||||
assert "push_status" not in item
|
assert "push_status" not in item
|
||||||
assert "multiPlatformFlag" not in item
|
assert "multiPlatformFlag" not in item
|
||||||
assert "createTime" not in item
|
assert "createTime" not in item
|
||||||
|
|
||||||
|
|
||||||
|
# 组装入口把 limit 传给仓库,让 SQL 侧截组。
|
||||||
|
def test_load_passes_limit_to_list_unpushed():
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
# 只记 list_unpushed 收到的 limit。
|
||||||
|
class SpyRepo:
|
||||||
|
# 记下上限并返回空列表。
|
||||||
|
def list_unpushed(self, limit: int = 500) -> list[dict]:
|
||||||
|
seen["limit"] = limit
|
||||||
|
return []
|
||||||
|
|
||||||
|
load_cart_settlement_envelope(SpyRepo(), limit=3)
|
||||||
|
assert seen["limit"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# OMS_OUTBOUND_BATCH_LIMIT 覆盖默认 500。
|
||||||
|
def test_batch_limit_reads_env(monkeypatch):
|
||||||
|
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "3")
|
||||||
|
assert batch_limit() == 3
|
||||||
|
|
||||||
|
|
||||||
|
# 非法或非正数上限回退 500。
|
||||||
|
def test_batch_limit_invalid_falls_back_to_500(monkeypatch):
|
||||||
|
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "abc")
|
||||||
|
assert batch_limit() == 500
|
||||||
|
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "0")
|
||||||
|
assert batch_limit() == 500
|
||||||
|
|
||||||
|
|
||||||
|
# 未显式传 limit 时按环境变量截组。
|
||||||
|
def test_load_uses_env_batch_limit(monkeypatch):
|
||||||
|
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "1")
|
||||||
|
repo, db = _open(
|
||||||
|
[
|
||||||
|
_row(fid=1, oms_id=1),
|
||||||
|
_row(fid=2, oms_id=2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
envelope = load_cart_settlement_envelope(repo)
|
||||||
|
assert [item["fid"] for item in envelope["data"]] == [1]
|
||||||
|
_sql, params = _last_execute(db)
|
||||||
|
assert params["limit"] == 1
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
"""接口 4:从表 4 取 push_status=0,组装文档报文。不连 Oracle,不发 HTTP。"""
|
# 接口 4:从表 4 取 push_status=0,组装文档报文。假连接录 SQL,不连真库,不发 HTTP。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.outbound.contract_agreement import (
|
from app.outbound.contract_agreement import (
|
||||||
MAX_BATCH,
|
MAX_BATCH,
|
||||||
|
batch_limit,
|
||||||
build_contract_agreement_envelope,
|
build_contract_agreement_envelope,
|
||||||
load_contract_agreement_envelope,
|
load_contract_agreement_envelope,
|
||||||
)
|
)
|
||||||
from app.outbound.memory_repo import MemoryContractAgreementRepo
|
from app.outbound.oracle_repo import OracleContractAgreementRepo
|
||||||
|
from tests.fake_oracle import FakeDb
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条表 4 行,供组装层单测用。
|
||||||
def _row(**overrides):
|
def _row(**overrides):
|
||||||
row = {
|
row = {
|
||||||
"fid": 300001,
|
"fid": 300001,
|
||||||
|
|
@ -28,8 +32,25 @@ def _row(**overrides):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 把表 4 行放进假库。list_unpushed 走真 SQL,调用记在 db.calls。
|
||||||
|
def _open(rows: list[dict]) -> tuple[OracleContractAgreementRepo, FakeDb]:
|
||||||
|
db = FakeDb()
|
||||||
|
for row in rows:
|
||||||
|
db.agreements[row["fid"]] = row
|
||||||
|
return OracleContractAgreementRepo(db.connection()), db
|
||||||
|
|
||||||
|
|
||||||
|
# 取出最近一次 execute 的 SQL 和绑定。
|
||||||
|
def _last_execute(db: FakeDb) -> tuple[str, dict]:
|
||||||
|
execute_calls = [call for call in db.calls if call[0] == "execute"]
|
||||||
|
assert execute_calls
|
||||||
|
_, sql, params = execute_calls[-1]
|
||||||
|
return sql, params or {}
|
||||||
|
|
||||||
|
|
||||||
|
# 未推行按文档信封字段组装;已推行跳过。
|
||||||
def test_assembles_unpushed_rows_as_document_envelope():
|
def test_assembles_unpushed_rows_as_document_envelope():
|
||||||
repo = MemoryContractAgreementRepo(
|
repo, db = _open(
|
||||||
[
|
[
|
||||||
_row(),
|
_row(),
|
||||||
_row(
|
_row(
|
||||||
|
|
@ -76,10 +97,15 @@ def test_assembles_unpushed_rows_as_document_envelope():
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
assert isinstance(envelope["data"][0]["omsId"], int)
|
assert isinstance(envelope["data"][0]["omsId"], int)
|
||||||
|
sql, params = _last_execute(db)
|
||||||
|
assert "rpa_contract_agreement_result" in sql.lower()
|
||||||
|
assert "push_status = 0" in sql.lower()
|
||||||
|
assert params["limit"] == 500
|
||||||
|
|
||||||
|
|
||||||
|
# 合同成功行和协议行可以同批。
|
||||||
def test_assembles_contract_success_and_agreement_rows():
|
def test_assembles_contract_success_and_agreement_rows():
|
||||||
repo = MemoryContractAgreementRepo(
|
repo, db = _open(
|
||||||
[
|
[
|
||||||
_row(contract_status=2),
|
_row(contract_status=2),
|
||||||
_row(
|
_row(
|
||||||
|
|
@ -100,8 +126,11 @@ def test_assembles_contract_success_and_agreement_rows():
|
||||||
assert envelope["data"][1]["agreementStatus"] == 1
|
assert envelope["data"][1]["agreementStatus"] == 1
|
||||||
assert envelope["data"][1]["agreementId"] == "AGR1"
|
assert envelope["data"][1]["agreementId"] == "AGR1"
|
||||||
assert envelope["data"][1]["agreementDtlId"] == "AGRD1"
|
assert envelope["data"][1]["agreementDtlId"] == "AGRD1"
|
||||||
|
sql, _params = _last_execute(db)
|
||||||
|
assert "rpa_contract_agreement_result" in sql.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# 空批时超限的同一 contractNo 仍整组进入。
|
||||||
def test_oversized_contract_no_group_enters_when_batch_empty():
|
def test_oversized_contract_no_group_enters_when_batch_empty():
|
||||||
rows = [
|
rows = [
|
||||||
_row(fid=1, oms_id=1, contract_no="HT-BIG"),
|
_row(fid=1, oms_id=1, contract_no="HT-BIG"),
|
||||||
|
|
@ -112,6 +141,7 @@ def test_oversized_contract_no_group_enters_when_batch_empty():
|
||||||
assert [item["fid"] for item in envelope["data"]] == [1, 2, 3]
|
assert [item["fid"] for item in envelope["data"]] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
# 批次已有行时,装不下的 contractNo 整组留下。
|
||||||
def test_does_not_split_contract_no_when_batch_already_has_rows():
|
def test_does_not_split_contract_no_when_batch_already_has_rows():
|
||||||
rows = [
|
rows = [
|
||||||
_row(fid=1, oms_id=1, contract_no="HT-A"),
|
_row(fid=1, oms_id=1, contract_no="HT-A"),
|
||||||
|
|
@ -122,6 +152,7 @@ def test_does_not_split_contract_no_when_batch_already_has_rows():
|
||||||
assert [item["fid"] for item in envelope["data"]] == [1]
|
assert [item["fid"] for item in envelope["data"]] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# 空 contractNo 按行独立,互不捆绑。
|
||||||
def test_empty_contract_no_rows_are_independent():
|
def test_empty_contract_no_rows_are_independent():
|
||||||
rows = [
|
rows = [
|
||||||
_row(fid=1, oms_id=1, contract_no=None, contract_status=3),
|
_row(fid=1, oms_id=1, contract_no=None, contract_status=3),
|
||||||
|
|
@ -132,11 +163,34 @@ def test_empty_contract_no_rows_are_independent():
|
||||||
assert [item["fid"] for item in envelope["data"]] == [1]
|
assert [item["fid"] for item in envelope["data"]] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# 默认上限 500。
|
||||||
def test_batch_cap_is_500():
|
def test_batch_cap_is_500():
|
||||||
assert MAX_BATCH == 500
|
assert MAX_BATCH == 500
|
||||||
|
|
||||||
|
|
||||||
|
# 报文不含 push_status。
|
||||||
def test_payload_omits_push_status():
|
def test_payload_omits_push_status():
|
||||||
item = build_contract_agreement_envelope([_row()])["data"][0]
|
item = build_contract_agreement_envelope([_row()])["data"][0]
|
||||||
assert "pushStatus" not in item
|
assert "pushStatus" not in item
|
||||||
assert "push_status" not in item
|
assert "push_status" not in item
|
||||||
|
|
||||||
|
|
||||||
|
# 组装入口把 limit 传给仓库,让 SQL 侧截组。
|
||||||
|
def test_load_passes_limit_to_list_unpushed():
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
# 只记 list_unpushed 收到的 limit。
|
||||||
|
class SpyRepo:
|
||||||
|
# 记下上限并返回空列表。
|
||||||
|
def list_unpushed(self, limit: int = 500) -> list[dict]:
|
||||||
|
seen["limit"] = limit
|
||||||
|
return []
|
||||||
|
|
||||||
|
load_contract_agreement_envelope(SpyRepo(), limit=3)
|
||||||
|
assert seen["limit"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# OMS_OUTBOUND_BATCH_LIMIT 覆盖默认 500。
|
||||||
|
def test_batch_limit_reads_env(monkeypatch):
|
||||||
|
monkeypatch.setenv("OMS_OUTBOUND_BATCH_LIMIT", "3")
|
||||||
|
assert batch_limit() == 3
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
"""本机 .env 只补缺,不覆盖已有环境变量。"""
|
# 本机 .env 只补缺,不覆盖已有非空环境变量。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from app.local_env import load_local_env
|
from rpa_oracle.env import load_env as load_local_env
|
||||||
|
|
||||||
|
|
||||||
|
# 缺的键从文件补上,已有非空环境变量保持不变。
|
||||||
def test_load_local_env_fills_missing_and_keeps_existing(tmp_path, monkeypatch):
|
def test_load_local_env_fills_missing_and_keeps_existing(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("OMS_REPO", "memory")
|
monkeypatch.setenv("OMS_REPO", "memory")
|
||||||
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
# 接口 4 Oracle 取数:SQL 按非空 contractNo 整组截断;一次 UPDATE 回写。不连真库。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.inbound.repo import RepoUnavailable
|
||||||
|
from app.outbound.oracle_repo import _LIST_AGREEMENT_SQL, OracleContractAgreementRepo
|
||||||
|
from tests.fake_oracle import FakeDb, ora_error
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条表 4 未推行。
|
||||||
|
def _agreement(**overrides) -> dict:
|
||||||
|
row = {
|
||||||
|
"fid": 1,
|
||||||
|
"oms_id": 1,
|
||||||
|
"action_code": 1,
|
||||||
|
"contract_status": 1,
|
||||||
|
"contract_fail_reason": None,
|
||||||
|
"contract_no": "HT1",
|
||||||
|
"agreement_status": None,
|
||||||
|
"agreement_fail_reason": None,
|
||||||
|
"agreement_id": None,
|
||||||
|
"agreement_dtl_id": None,
|
||||||
|
"org_code": "901",
|
||||||
|
"push_status": 0,
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 取数 SQL 只捞 push_status=0,按非空合同号分组,并用 :limit 在库侧截断。
|
||||||
|
def test_list_sql_filters_unpushed_and_partitions_by_contract_no():
|
||||||
|
sql = " ".join(_LIST_AGREEMENT_SQL.split()).lower()
|
||||||
|
assert "rpa_contract_agreement_result" in sql
|
||||||
|
assert "push_status = 0" in sql
|
||||||
|
assert "partition by" in sql
|
||||||
|
assert "contract_no" in sql
|
||||||
|
assert ":limit" in sql
|
||||||
|
assert "running_start" in sql
|
||||||
|
|
||||||
|
|
||||||
|
# limit=3 时同一非空 contractNo 三行整组进批,多出的另一组留下。
|
||||||
|
def test_list_unpushed_keeps_whole_contract_no_group_when_limit_is_3():
|
||||||
|
db = FakeDb()
|
||||||
|
db.agreements[1] = _agreement(fid=1, oms_id=1, contract_no="HT-A")
|
||||||
|
db.agreements[2] = _agreement(fid=2, oms_id=2, contract_no="HT-A")
|
||||||
|
db.agreements[3] = _agreement(fid=3, oms_id=3, contract_no="HT-A")
|
||||||
|
db.agreements[4] = _agreement(fid=4, oms_id=4, contract_no="HT-B")
|
||||||
|
repo = OracleContractAgreementRepo(db.connection())
|
||||||
|
rows = repo.list_unpushed(limit=3)
|
||||||
|
assert [row["fid"] for row in rows] == [1, 2, 3]
|
||||||
|
execute_calls = [call for call in db.calls if call[0] == "execute"]
|
||||||
|
sql, params = execute_calls[-1][1], execute_calls[-1][2]
|
||||||
|
assert "partition by" in sql.lower()
|
||||||
|
assert params["limit"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# 库故障对外固定「数据库不可用」,不含 ORA 原文。
|
||||||
|
def test_list_unpushed_hides_ora_text():
|
||||||
|
db = FakeDb()
|
||||||
|
db.raise_execute = ora_error(12541, "ORA-12541: TNS:no listener")
|
||||||
|
repo = OracleContractAgreementRepo(db.connection())
|
||||||
|
with pytest.raises(RepoUnavailable) as caught:
|
||||||
|
repo.list_unpushed(limit=500)
|
||||||
|
assert caught.value.message == "数据库不可用"
|
||||||
|
assert "ORA-" not in str(caught.value)
|
||||||
|
|
||||||
|
|
||||||
|
# 回写 push_status 只发一条 UPDATE ... WHERE fid IN (...)。
|
||||||
|
def test_mark_pushed_updates_in_one_statement():
|
||||||
|
db = FakeDb()
|
||||||
|
db.agreements[1] = _agreement(fid=1)
|
||||||
|
db.agreements[2] = _agreement(fid=2)
|
||||||
|
repo = OracleContractAgreementRepo(db.connection())
|
||||||
|
repo.mark_pushed([1, 2], status=1, reason="ok")
|
||||||
|
updates = [
|
||||||
|
call for call in db.calls if call[0] == "execute" and "UPDATE" in call[1].upper()
|
||||||
|
]
|
||||||
|
assert len(updates) == 1
|
||||||
|
sql = updates[0][1].upper()
|
||||||
|
assert "RPA_CONTRACT_AGREEMENT_RESULT" in sql
|
||||||
|
assert "FID IN" in sql
|
||||||
|
assert db.agreements[1]["push_status"] == 1
|
||||||
|
assert db.agreements[2]["push_result"] == "ok"
|
||||||
|
assert db.commits == 1
|
||||||
|
|
@ -1,77 +1,136 @@
|
||||||
"""接口 2 Oracle 取数:只捞 push_status=0。不连真库。"""
|
# 接口 2 Oracle 取数:SQL 按 oms_id 整组截断;一次 UPDATE 回写。不连真库。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.inbound.repo import RepoUnavailable
|
||||||
from app.outbound.oracle_repo import _LIST_UNPUSHED_SQL, OracleCartSettlementRepo
|
from app.outbound.oracle_repo import _LIST_UNPUSHED_SQL, OracleCartSettlementRepo
|
||||||
|
from tests.fake_oracle import FakeDb, ora_error
|
||||||
|
|
||||||
|
|
||||||
def test_list_sql_filters_unpushed_and_orders_by_oms_id():
|
# 构造一条表 2 未推行。
|
||||||
sql = " ".join(_LIST_UNPUSHED_SQL.split())
|
def _cart(**overrides) -> dict:
|
||||||
assert "rpa_cart_settlement_goods_data" in sql.lower()
|
row = {
|
||||||
|
"fid": 1,
|
||||||
|
"operate_type": 1,
|
||||||
|
"oms_id": 1,
|
||||||
|
"platform_type": 48,
|
||||||
|
"supplier_name": "示例供应商",
|
||||||
|
"bar_code": "06901234567890",
|
||||||
|
"purchase_price": 128.5,
|
||||||
|
"purchase_qty": 10,
|
||||||
|
"purchase_total": 1285,
|
||||||
|
"status": 1,
|
||||||
|
"fail_reason": None,
|
||||||
|
"pay_order_no": None,
|
||||||
|
"pay_order_dtl_id": None,
|
||||||
|
"org_code": "901",
|
||||||
|
"push_status": 0,
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 取数 SQL 只捞 push_status=0,按 oms_id 分组,并用 :limit 在库侧截断。
|
||||||
|
def test_list_sql_filters_unpushed_and_partitions_by_oms_id():
|
||||||
|
sql = " ".join(_LIST_UNPUSHED_SQL.split()).lower()
|
||||||
|
assert "rpa_cart_settlement_goods_data" in sql
|
||||||
assert "push_status = 0" in sql
|
assert "push_status = 0" in sql
|
||||||
assert "order by oms_id" in sql.lower()
|
assert "partition by" in sql
|
||||||
assert "fid" in sql.lower()
|
assert "oms_id" in sql
|
||||||
|
assert ":limit" in sql
|
||||||
|
assert "running_start" in sql
|
||||||
|
assert "order by" in sql and "oms_id" in sql
|
||||||
|
|
||||||
|
|
||||||
|
# 假连接返回的列名转小写后能对上组装层字段。
|
||||||
def test_list_unpushed_maps_columns():
|
def test_list_unpushed_maps_columns():
|
||||||
class FakeCursor:
|
db = FakeDb()
|
||||||
description = (
|
db.carts[100001] = _cart(
|
||||||
("FID",),
|
fid=100001,
|
||||||
("OPERATE_TYPE",),
|
oms_id=20260928000001,
|
||||||
("OMS_ID",),
|
bar_code="06901234567890",
|
||||||
("PLATFORM_TYPE",),
|
|
||||||
("SUPPLIER_NAME",),
|
|
||||||
("BAR_CODE",),
|
|
||||||
("PURCHASE_PRICE",),
|
|
||||||
("PURCHASE_QTY",),
|
|
||||||
("PURCHASE_TOTAL",),
|
|
||||||
("STATUS",),
|
|
||||||
("FAIL_REASON",),
|
|
||||||
("PAY_ORDER_NO",),
|
|
||||||
("PAY_ORDER_DTL_ID",),
|
|
||||||
("ORG_CODE",),
|
|
||||||
("PUSH_STATUS",),
|
|
||||||
)
|
)
|
||||||
|
repo = OracleCartSettlementRepo(db.connection())
|
||||||
def __enter__(self):
|
rows = repo.list_unpushed(limit=500)
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
self.sql = sql
|
|
||||||
|
|
||||||
def fetchall(self):
|
|
||||||
return [
|
|
||||||
(
|
|
||||||
100001,
|
|
||||||
1,
|
|
||||||
20260928000001,
|
|
||||||
48,
|
|
||||||
"示例供应商",
|
|
||||||
"06901234567890",
|
|
||||||
128.5,
|
|
||||||
10,
|
|
||||||
1285,
|
|
||||||
1,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
"901",
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
class FakeConn:
|
|
||||||
def cursor(self):
|
|
||||||
return FakeCursor()
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
repo = OracleCartSettlementRepo(connect=lambda: FakeConn())
|
|
||||||
rows = repo.list_unpushed()
|
|
||||||
assert rows[0]["fid"] == 100001
|
assert rows[0]["fid"] == 100001
|
||||||
assert rows[0]["operate_type"] == 1
|
assert rows[0]["operate_type"] == 1
|
||||||
assert rows[0]["bar_code"] == "06901234567890"
|
assert rows[0]["bar_code"] == "06901234567890"
|
||||||
assert rows[0]["push_status"] == 0
|
assert rows[0]["push_status"] == 0
|
||||||
|
assert db.closes == 0
|
||||||
|
|
||||||
|
|
||||||
|
# limit=3 时同一 omsId 三行整组进批,多出的另一组留下。
|
||||||
|
def test_list_unpushed_keeps_whole_oms_group_when_limit_is_3():
|
||||||
|
db = FakeDb()
|
||||||
|
db.carts[1] = _cart(fid=1, oms_id=1)
|
||||||
|
db.carts[2] = _cart(fid=2, oms_id=1)
|
||||||
|
db.carts[3] = _cart(fid=3, oms_id=1)
|
||||||
|
db.carts[4] = _cart(fid=4, oms_id=2)
|
||||||
|
repo = OracleCartSettlementRepo(db.connection())
|
||||||
|
rows = repo.list_unpushed(limit=3)
|
||||||
|
assert [row["fid"] for row in rows] == [1, 2, 3]
|
||||||
|
sql, params = _last_execute(db)
|
||||||
|
assert "partition by" in sql.lower()
|
||||||
|
assert params["limit"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# 库故障对外固定「数据库不可用」,不含 ORA 原文。
|
||||||
|
def test_list_unpushed_hides_ora_text():
|
||||||
|
db = FakeDb()
|
||||||
|
db.raise_execute = ora_error(12541, "ORA-12541: TNS:no listener")
|
||||||
|
repo = OracleCartSettlementRepo(db.connection())
|
||||||
|
with pytest.raises(RepoUnavailable) as caught:
|
||||||
|
repo.list_unpushed(limit=500)
|
||||||
|
assert caught.value.message == "数据库不可用"
|
||||||
|
assert "ORA-" not in str(caught.value)
|
||||||
|
|
||||||
|
|
||||||
|
# 回写 push_status 只发一条 UPDATE ... WHERE fid IN (...)。
|
||||||
|
def test_mark_pushed_updates_in_one_statement():
|
||||||
|
db = FakeDb()
|
||||||
|
db.carts[1] = _cart(fid=1)
|
||||||
|
db.carts[2] = _cart(fid=2)
|
||||||
|
repo = OracleCartSettlementRepo(db.connection())
|
||||||
|
repo.mark_pushed([1, 2], status=1, reason="ok")
|
||||||
|
updates = [
|
||||||
|
call for call in db.calls if call[0] == "execute" and "UPDATE" in call[1].upper()
|
||||||
|
]
|
||||||
|
assert len(updates) == 1
|
||||||
|
sql = updates[0][1].upper()
|
||||||
|
assert "RPA_CART_SETTLEMENT_GOODS_DATA" in sql
|
||||||
|
assert "FID IN" in sql
|
||||||
|
assert db.carts[1]["push_status"] == 1
|
||||||
|
assert db.carts[2]["push_status"] == 1
|
||||||
|
assert db.carts[1]["push_result"] == "ok"
|
||||||
|
assert db.commits == 1
|
||||||
|
|
||||||
|
|
||||||
|
# 空 fid 列表不发 SQL、不 commit。
|
||||||
|
def test_mark_pushed_empty_fids_does_not_touch_db():
|
||||||
|
db = FakeDb()
|
||||||
|
repo = OracleCartSettlementRepo(db.connection())
|
||||||
|
repo.mark_pushed([], status=1, reason=None)
|
||||||
|
assert db.calls == []
|
||||||
|
assert db.commits == 0
|
||||||
|
|
||||||
|
|
||||||
|
# 回写失败对外固定「数据库不可用」。
|
||||||
|
def test_mark_pushed_hides_ora_text():
|
||||||
|
db = FakeDb()
|
||||||
|
db.carts[1] = _cart(fid=1)
|
||||||
|
db.raise_execute = ora_error(3113, "ORA-03113: end-of-file on communication channel")
|
||||||
|
repo = OracleCartSettlementRepo(db.connection())
|
||||||
|
with pytest.raises(RepoUnavailable) as caught:
|
||||||
|
repo.mark_pushed([1], status=2, reason="fail")
|
||||||
|
assert caught.value.message == "数据库不可用"
|
||||||
|
assert "ORA-" not in str(caught.value)
|
||||||
|
|
||||||
|
|
||||||
|
# 取出最近一次 execute 的 SQL 和绑定。
|
||||||
|
def _last_execute(db: FakeDb) -> tuple[str, dict]:
|
||||||
|
execute_calls = [call for call in db.calls if call[0] == "execute"]
|
||||||
|
assert execute_calls
|
||||||
|
_, sql, params = execute_calls[-1]
|
||||||
|
return sql, params or {}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""接口 3 Oracle 仓库:INSERT 绑 fid;ORA-00001 映射成 DuplicateFid。不连真库。"""
|
# 接口 3 Oracle 仓库:INSERT 绑 fid;按主键认重复;一批 executemany。不连真库。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
@ -6,11 +7,13 @@ from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.inbound.repo import DuplicateFid, RepoUnavailable
|
from app.inbound.repo import RepoUnavailable
|
||||||
|
from tests.fake_oracle import FakeDb
|
||||||
|
|
||||||
|
|
||||||
def _record():
|
# 构造一条表 3 落库记录。
|
||||||
return {
|
def _record(**overrides):
|
||||||
|
row = {
|
||||||
"fid": 200001,
|
"fid": 200001,
|
||||||
"oms_id": 20260928000001,
|
"oms_id": 20260928000001,
|
||||||
"supplier_id": "SUP001",
|
"supplier_id": "SUP001",
|
||||||
|
|
@ -40,51 +43,61 @@ def _record():
|
||||||
"supplier_mdm_code": "DW0100046361",
|
"supplier_mdm_code": "DW0100046361",
|
||||||
"process_status": 0,
|
"process_status": 0,
|
||||||
}
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 缺 host 时 get_pool 抛「数据库不可用」。
|
||||||
def test_from_env_without_host_raises(monkeypatch):
|
def test_from_env_without_host_raises(monkeypatch):
|
||||||
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
||||||
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
import app.main as main
|
||||||
|
|
||||||
with pytest.raises(RepoUnavailable):
|
main._pool = None
|
||||||
OraclePurchaseContractRepo.from_env()
|
with pytest.raises(RepoUnavailable) as caught:
|
||||||
|
main.get_pool()
|
||||||
|
assert caught.value.message == "数据库不可用"
|
||||||
|
|
||||||
|
|
||||||
|
# pk_rpa_pcd 冲突时 insert_many 把该 fid 放进重复集合。
|
||||||
def test_insert_unique_violation_is_duplicate_fid():
|
def test_insert_unique_violation_is_duplicate_fid():
|
||||||
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
||||||
|
|
||||||
class FakeError(Exception):
|
db = FakeDb()
|
||||||
def __init__(self):
|
db.collide_contracts.add(200001)
|
||||||
self.args = (type("Info", (), {"code": 1})(),)
|
repo = OraclePurchaseContractRepo(db.connection())
|
||||||
|
collided = repo.insert_many([_record()])
|
||||||
class FakeCursor:
|
assert collided == {200001}
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
raise FakeError()
|
|
||||||
|
|
||||||
class FakeConn:
|
|
||||||
def cursor(self):
|
|
||||||
return FakeCursor()
|
|
||||||
|
|
||||||
def commit(self):
|
|
||||||
raise AssertionError("unique 冲突不应 commit")
|
|
||||||
|
|
||||||
def rollback(self):
|
|
||||||
self.rolled_back = True
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
repo = OraclePurchaseContractRepo(connect=lambda: FakeConn())
|
|
||||||
with pytest.raises(DuplicateFid):
|
|
||||||
repo.insert(_record())
|
|
||||||
|
|
||||||
|
|
||||||
|
# 未知唯一约束不当成 fid 重复,抛「数据库不可用」。
|
||||||
|
def test_unknown_unique_constraint_raises_unavailable():
|
||||||
|
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
||||||
|
|
||||||
|
db = FakeDb()
|
||||||
|
db.collide_contracts.add(200001)
|
||||||
|
db.collide_constraint = "UK_SOMETHING_ELSE"
|
||||||
|
repo = OraclePurchaseContractRepo(db.connection())
|
||||||
|
with pytest.raises(RepoUnavailable) as caught:
|
||||||
|
repo.insert_many([_record()])
|
||||||
|
assert caught.value.message == "数据库不可用"
|
||||||
|
|
||||||
|
|
||||||
|
# 待插入多行时一次 executemany,且 batcherrors=True。
|
||||||
|
def test_insert_many_uses_executemany():
|
||||||
|
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
||||||
|
|
||||||
|
db = FakeDb()
|
||||||
|
repo = OraclePurchaseContractRepo(db.connection())
|
||||||
|
assert repo.existing_fids([1, 2]) == set()
|
||||||
|
collided = repo.insert_many([_record(fid=1, oms_id=1), _record(fid=2, oms_id=2)])
|
||||||
|
assert collided == set()
|
||||||
|
assert set(db.contracts) == {1, 2}
|
||||||
|
many = [c for c in db.calls if c[0] == "executemany"]
|
||||||
|
assert len(many) == 1
|
||||||
|
assert many[0][3] is True
|
||||||
|
|
||||||
|
|
||||||
|
# INSERT 绑来源 fid,process_status 用字面量 0。
|
||||||
def test_insert_sql_binds_source_fid_and_pending_status():
|
def test_insert_sql_binds_source_fid_and_pending_status():
|
||||||
from app.inbound.oracle_contract_repo import _BIND_KEYS, _INSERT_SQL
|
from app.inbound.oracle_contract_repo import _BIND_KEYS, _INSERT_SQL
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
"""Oracle 仓库:缺配置 503;ORA-00001 映射成 DuplicateOmsId。不连真库。"""
|
# 接口 1 Oracle 仓库:缺配置 503;按约束名认重复;一批 executemany。不连真库。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("OMS_INBOUND_TOKEN", "test-oms-token")
|
from app.inbound.repo import RepoUnavailable
|
||||||
|
from tests.fake_oracle import FakeDb, ora_error
|
||||||
from app.inbound.repo import DuplicateOmsId, RepoUnavailable
|
|
||||||
|
|
||||||
|
|
||||||
def _record():
|
# 构造一条表 1 落库记录。
|
||||||
from datetime import date
|
def _record(**overrides):
|
||||||
from decimal import Decimal
|
row = {
|
||||||
|
|
||||||
return {
|
|
||||||
"oms_id": 2026092801,
|
"oms_id": 2026092801,
|
||||||
"bar_code": "06901234567890",
|
"bar_code": "06901234567890",
|
||||||
"order_date": date(2026, 9, 28),
|
"order_date": date(2026, 9, 28),
|
||||||
|
|
@ -34,52 +33,109 @@ def _record():
|
||||||
"customer_mdm_code": "DW0100079727",
|
"customer_mdm_code": "DW0100079727",
|
||||||
"deal_flag": 0,
|
"deal_flag": 0,
|
||||||
}
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 并发首个请求只建一个池。
|
||||||
|
def test_get_pool_creates_once_under_concurrency(monkeypatch):
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
|
||||||
|
created: list[object] = []
|
||||||
|
|
||||||
|
# 拖住建池,让多个线程同时穿过「还没有池」的窗口。
|
||||||
|
def fake_create_pool(*, role: str = "oms"):
|
||||||
|
time.sleep(0.05)
|
||||||
|
pool = object()
|
||||||
|
created.append(pool)
|
||||||
|
return pool
|
||||||
|
|
||||||
|
monkeypatch.setattr("rpa_oracle.pool.create_pool", fake_create_pool)
|
||||||
|
main._pool = None
|
||||||
|
barrier = threading.Barrier(8)
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
|
||||||
|
# 同时打 get_pool。
|
||||||
|
def worker() -> None:
|
||||||
|
try:
|
||||||
|
barrier.wait()
|
||||||
|
main.get_pool()
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker) for _ in range(8)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
try:
|
||||||
|
assert errors == []
|
||||||
|
assert len(created) == 1
|
||||||
|
assert main._pool is created[0]
|
||||||
|
finally:
|
||||||
|
main._pool = None
|
||||||
|
|
||||||
|
|
||||||
|
# 缺用户时 get_pool 抛「数据库不可用」,文案不含缺项名单。
|
||||||
def test_from_env_without_user_raises(monkeypatch):
|
def test_from_env_without_user_raises(monkeypatch):
|
||||||
monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
|
monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
|
||||||
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
|
||||||
from app.inbound.oracle_repo import OracleSalesGoodsRepo
|
import app.main as main
|
||||||
|
|
||||||
with pytest.raises(RepoUnavailable):
|
main._pool = None
|
||||||
OracleSalesGoodsRepo.from_env()
|
with pytest.raises(RepoUnavailable) as caught:
|
||||||
|
main.get_pool()
|
||||||
|
assert caught.value.message == "数据库不可用"
|
||||||
|
assert "RPA_ORACLE" not in str(caught.value)
|
||||||
|
|
||||||
|
|
||||||
|
# uk_rpa_sgmd_oms_id 冲突时 insert_many 把该 oms_id 放进重复集合。
|
||||||
def test_insert_unique_violation_is_duplicate():
|
def test_insert_unique_violation_is_duplicate():
|
||||||
from app.inbound.oracle_repo import OracleSalesGoodsRepo
|
from app.inbound.oracle_repo import OracleSalesGoodsRepo
|
||||||
|
|
||||||
class FakeError(Exception):
|
db = FakeDb()
|
||||||
def __init__(self):
|
db.collide_sales.add(2026092801)
|
||||||
self.args = (type("Info", (), {"code": 1})(),)
|
repo = OracleSalesGoodsRepo(db.connection())
|
||||||
|
collided = repo.insert_many([_record()])
|
||||||
class FakeCursor:
|
assert collided == {2026092801}
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
raise FakeError()
|
|
||||||
|
|
||||||
class FakeConn:
|
|
||||||
def cursor(self):
|
|
||||||
return FakeCursor()
|
|
||||||
|
|
||||||
def commit(self):
|
|
||||||
raise AssertionError("unique 冲突不应 commit")
|
|
||||||
|
|
||||||
def rollback(self):
|
|
||||||
self.rolled_back = True
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
repo = OracleSalesGoodsRepo(connect=lambda: FakeConn())
|
|
||||||
with pytest.raises(DuplicateOmsId):
|
|
||||||
repo.insert(_record())
|
|
||||||
|
|
||||||
|
|
||||||
|
# 未知唯一约束不当成 oms_id 重复,抛「数据库不可用」。
|
||||||
|
def test_unknown_unique_constraint_raises_unavailable():
|
||||||
|
from app.inbound.oracle_repo import OracleSalesGoodsRepo
|
||||||
|
|
||||||
|
db = FakeDb()
|
||||||
|
db.collide_sales.add(2026092801)
|
||||||
|
db.collide_constraint = "UK_SOMETHING_ELSE"
|
||||||
|
repo = OracleSalesGoodsRepo(db.connection())
|
||||||
|
with pytest.raises(RepoUnavailable) as caught:
|
||||||
|
repo.insert_many([_record()])
|
||||||
|
assert caught.value.message == "数据库不可用"
|
||||||
|
|
||||||
|
|
||||||
|
# 待插入多行时先 SELECT IN 再一次 executemany。
|
||||||
|
def test_insert_many_uses_executemany_and_select_in():
|
||||||
|
from app.inbound.oracle_repo import OracleSalesGoodsRepo
|
||||||
|
|
||||||
|
db = FakeDb()
|
||||||
|
conn = db.connection()
|
||||||
|
repo = OracleSalesGoodsRepo(conn)
|
||||||
|
existing = repo.existing_oms_ids([1, 2])
|
||||||
|
assert existing == set()
|
||||||
|
collided = repo.insert_many([_record(oms_id=1), _record(oms_id=2)])
|
||||||
|
assert collided == set()
|
||||||
|
assert set(db.sales) == {1, 2}
|
||||||
|
selects = [c for c in db.calls if c[0] == "execute" and "SELECT" in c[1].upper()]
|
||||||
|
assert any("IN" in c[1].upper() for c in selects)
|
||||||
|
many = [c for c in db.calls if c[0] == "executemany"]
|
||||||
|
assert len(many) == 1
|
||||||
|
assert many[0][3] is True
|
||||||
|
|
||||||
|
|
||||||
|
# INSERT 仍包含选品班要读的列。
|
||||||
def test_insert_sql_includes_select_job_columns():
|
def test_insert_sql_includes_select_job_columns():
|
||||||
from app.inbound.oracle_repo import _BIND_KEYS, _INSERT_SQL
|
from app.inbound.oracle_repo import _BIND_KEYS, _INSERT_SQL
|
||||||
|
|
||||||
|
|
@ -108,6 +164,7 @@ def test_insert_sql_includes_select_job_columns():
|
||||||
assert "deal_flag" in sql
|
assert "deal_flag" in sql
|
||||||
|
|
||||||
|
|
||||||
|
# 创建人/更新人固定 0 / 系统管理员,时间用 SYSDATE。
|
||||||
def test_insert_sql_sets_system_auditor():
|
def test_insert_sql_sets_system_auditor():
|
||||||
from app.inbound.oracle_repo import _INSERT_SQL
|
from app.inbound.oracle_repo import _INSERT_SQL
|
||||||
|
|
||||||
|
|
@ -119,3 +176,16 @@ def test_insert_sql_sets_system_auditor():
|
||||||
assert "update_user_name" in sql
|
assert "update_user_name" in sql
|
||||||
assert "SYSDATE, 0, '系统管理员'" in sql
|
assert "SYSDATE, 0, '系统管理员'" in sql
|
||||||
assert sql.count("SYSDATE") == 2
|
assert sql.count("SYSDATE") == 2
|
||||||
|
|
||||||
|
|
||||||
|
# 非唯一约束的 ORA 对外仍是「数据库不可用」,不带原文。
|
||||||
|
def test_other_ora_error_message_is_fixed():
|
||||||
|
from app.inbound.oracle_repo import OracleSalesGoodsRepo
|
||||||
|
|
||||||
|
db = FakeDb()
|
||||||
|
db.raise_execute = ora_error(12170, "ORA-12170: TNS:Connect timeout occurred")
|
||||||
|
repo = OracleSalesGoodsRepo(db.connection())
|
||||||
|
with pytest.raises(RepoUnavailable) as caught:
|
||||||
|
repo.existing_oms_ids([1])
|
||||||
|
assert caught.value.message == "数据库不可用"
|
||||||
|
assert "ORA-" not in str(caught.value)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""接口 3 收件契约。内存仓库,不连 Oracle。按对方 fid 幂等。"""
|
# 接口 3 收件契约。假连接录 SQL,不连 Oracle。按对方 fid 幂等。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -8,14 +9,14 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token"
|
os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token"
|
||||||
|
|
||||||
from app.inbound.memory_repo import MemoryPurchaseContractRepo
|
from app.main import app, get_pool
|
||||||
from app.inbound.repo import RepoUnavailable
|
from tests.fake_oracle import FakePool, ora_error
|
||||||
from app.main import app, get_contract_repo
|
|
||||||
|
|
||||||
TOKEN = "test-oms-token"
|
TOKEN = "test-oms-token"
|
||||||
PATH = "/api/oms/v1/purchase-contracts"
|
PATH = "/api/oms/v1/purchase-contracts"
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条合法的接口 3 明细,overrides 覆盖字段。
|
||||||
def _item(**overrides):
|
def _item(**overrides):
|
||||||
row = {
|
row = {
|
||||||
"fid": 200001,
|
"fid": 200001,
|
||||||
|
|
@ -50,44 +51,51 @@ def _item(**overrides):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 注入 FakePool,整次请求走同一块假库。
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
repo = MemoryPurchaseContractRepo()
|
pool = FakePool()
|
||||||
app.dependency_overrides[get_contract_repo] = lambda: repo
|
app.dependency_overrides[get_pool] = lambda: pool
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
yield test_client, repo
|
yield test_client, pool
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# 带或不带 X-Oms-Token 发 POST。
|
||||||
def _post(client, body, token=TOKEN):
|
def _post(client, body, token=TOKEN):
|
||||||
headers = {"X-Oms-Token": token} if token is not None else {}
|
headers = {"X-Oms-Token": token} if token is not None else {}
|
||||||
return client.post(PATH, json=body, headers=headers)
|
return client.post(PATH, json=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
# 缺 X-Oms-Token 时接口返回 401。
|
||||||
def test_missing_token_returns_401(client):
|
def test_missing_token_returns_401(client):
|
||||||
test_client, _ = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {"data": [_item()]}, token=None)
|
resp = _post(test_client, {"data": [_item()]}, token=None)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert resp.json()["results"] == []
|
assert resp.json()["results"] == []
|
||||||
|
assert pool.db.audits
|
||||||
|
|
||||||
|
|
||||||
|
# 缺 data 节点时整包 422,一条都不落。
|
||||||
def test_missing_data_returns_422(client):
|
def test_missing_data_returns_422(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {})
|
resp = _post(test_client, {})
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
assert repo.count() == 0
|
assert pool.db.contracts == {}
|
||||||
|
|
||||||
|
|
||||||
|
# 超过 100 条时整包 422,假库仍空。
|
||||||
def test_over_100_rows_returns_422(client):
|
def test_over_100_rows_returns_422(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
rows = [_item(fid=i, omsId=i) for i in range(1, 102)]
|
rows = [_item(fid=i, omsId=i) for i in range(1, 102)]
|
||||||
resp = _post(test_client, {"data": rows})
|
resp = _post(test_client, {"data": rows})
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
assert repo.count() == 0
|
assert pool.db.contracts == {}
|
||||||
|
|
||||||
|
|
||||||
|
# 合法一行 ACCEPTED,process_status=0,fid 原样落库。
|
||||||
def test_accepts_valid_row(client):
|
def test_accepts_valid_row(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {"data": [_item()]})
|
resp = _post(test_client, {"data": [_item()]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
|
|
@ -99,7 +107,7 @@ def test_accepts_valid_row(client):
|
||||||
"message": "采购合同数据已接收,等待处理",
|
"message": "采购合同数据已接收,等待处理",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
stored = repo.get(200001)
|
stored = pool.db.contracts[200001]
|
||||||
assert stored["process_status"] == 0
|
assert stored["process_status"] == 0
|
||||||
assert stored["oms_id"] == 20260928000001
|
assert stored["oms_id"] == 20260928000001
|
||||||
assert stored["supplier_id"] == "SUP001"
|
assert stored["supplier_id"] == "SUP001"
|
||||||
|
|
@ -108,19 +116,21 @@ def test_accepts_valid_row(client):
|
||||||
assert stored["contract_no"] is None
|
assert stored["contract_no"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# 跨请求同 fid:DUPLICATE,库里品名不改。
|
||||||
def test_existing_fid_is_duplicate_and_not_updated(client):
|
def test_existing_fid_is_duplicate_and_not_updated(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
_post(test_client, {"data": [_item()]})
|
_post(test_client, {"data": [_item()]})
|
||||||
resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]})
|
resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
result = resp.json()["results"][0]
|
result = resp.json()["results"][0]
|
||||||
assert result["resultCode"] == "DUPLICATE"
|
assert result["resultCode"] == "DUPLICATE"
|
||||||
assert result["message"] == "序号已存在,未重复入库"
|
assert result["message"] == "序号已存在,未重复入库"
|
||||||
assert repo.get(200001)["goods_name"] == "阿莫西林胶囊"
|
assert pool.db.contracts[200001]["goods_name"] == "阿莫西林胶囊"
|
||||||
|
|
||||||
|
|
||||||
|
# 同一 POST 里两条相同 fid:后一条 CONFLICT,只落第一条。
|
||||||
def test_duplicate_fid_in_same_batch_is_conflict(client):
|
def test_duplicate_fid_in_same_batch_is_conflict(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(
|
resp = _post(
|
||||||
test_client,
|
test_client,
|
||||||
{"data": [_item(), _item(omsId=20260928000002)]},
|
{"data": [_item(), _item(omsId=20260928000002)]},
|
||||||
|
|
@ -130,63 +140,69 @@ def test_duplicate_fid_in_same_batch_is_conflict(client):
|
||||||
assert results[0]["resultCode"] == "ACCEPTED"
|
assert results[0]["resultCode"] == "ACCEPTED"
|
||||||
assert results[1]["resultCode"] == "CONFLICT"
|
assert results[1]["resultCode"] == "CONFLICT"
|
||||||
assert results[1]["fid"] == 200001
|
assert results[1]["fid"] == 200001
|
||||||
assert repo.count() == 1
|
assert len(pool.db.contracts) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# contractEntryStatus=0 却没有合同号时 REJECTED。
|
||||||
def test_retry_protocol_without_contract_no_is_rejected(client):
|
def test_retry_protocol_without_contract_no_is_rejected(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {"data": [_item(contractEntryStatus=0, contractNo=None)]})
|
resp = _post(test_client, {"data": [_item(contractEntryStatus=0, contractNo=None)]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
result = resp.json()["results"][0]
|
result = resp.json()["results"][0]
|
||||||
assert result["resultCode"] == "REJECTED"
|
assert result["resultCode"] == "REJECTED"
|
||||||
assert "contractNo" in result["message"]
|
assert "contractNo" in result["message"]
|
||||||
assert repo.count() == 0
|
assert pool.db.contracts == {}
|
||||||
|
|
||||||
|
|
||||||
|
# contractEntryStatus=0 且带合同号时 ACCEPTED。
|
||||||
def test_retry_protocol_with_contract_no_is_accepted(client):
|
def test_retry_protocol_with_contract_no_is_accepted(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(
|
resp = _post(
|
||||||
test_client,
|
test_client,
|
||||||
{"data": [_item(fid=200002, contractEntryStatus=0, contractNo="HT20260928002")]},
|
{"data": [_item(fid=200002, contractEntryStatus=0, contractNo="HT20260928002")]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["results"][0]["resultCode"] == "ACCEPTED"
|
assert resp.json()["results"][0]["resultCode"] == "ACCEPTED"
|
||||||
stored = repo.get(200002)
|
stored = pool.db.contracts[200002]
|
||||||
assert stored["contract_entry_status"] == 0
|
assert stored["contract_entry_status"] == 0
|
||||||
assert stored["contract_no"] == "HT20260928002"
|
assert stored["contract_no"] == "HT20260928002"
|
||||||
|
|
||||||
|
|
||||||
|
# 结束日早于开始日时 REJECTED。
|
||||||
def test_end_date_before_start_is_rejected(client):
|
def test_end_date_before_start_is_rejected(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(
|
resp = _post(
|
||||||
test_client,
|
test_client,
|
||||||
{"data": [_item(contractStartDate="2027-09-30", contractEndDate="2026-10-01")]},
|
{"data": [_item(contractStartDate="2027-09-30", contractEndDate="2026-10-01")]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["results"][0]["resultCode"] == "REJECTED"
|
assert resp.json()["results"][0]["resultCode"] == "REJECTED"
|
||||||
assert repo.count() == 0
|
assert pool.db.contracts == {}
|
||||||
|
|
||||||
|
|
||||||
|
# JSON 字符串 omsId 不转整数,标 REJECTED。
|
||||||
def test_string_oms_id_is_rejected(client):
|
def test_string_oms_id_is_rejected(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {"data": [_item(omsId="20260928000001")]})
|
resp = _post(test_client, {"data": [_item(omsId="20260928000001")]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["results"][0]["resultCode"] == "REJECTED"
|
assert resp.json()["results"][0]["resultCode"] == "REJECTED"
|
||||||
assert repo.count() == 0
|
assert pool.db.contracts == {}
|
||||||
|
|
||||||
|
|
||||||
|
# 缺货品 MDM 码 REJECTED,不入库。
|
||||||
def test_missing_mdm_is_rejected_and_not_stored(client):
|
def test_missing_mdm_is_rejected_and_not_stored(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {"data": [_item(goodsMdmCode="")]})
|
resp = _post(test_client, {"data": [_item(goodsMdmCode="")]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
result = resp.json()["results"][0]
|
result = resp.json()["results"][0]
|
||||||
assert result["resultCode"] == "REJECTED"
|
assert result["resultCode"] == "REJECTED"
|
||||||
assert "goodsMdmCode" in result["message"]
|
assert "goodsMdmCode" in result["message"]
|
||||||
assert repo.count() == 0
|
assert pool.db.contracts == {}
|
||||||
|
|
||||||
|
|
||||||
|
# 混批 DUPLICATE + ACCEPTED + REJECTED 时 HTTP 仍 200。
|
||||||
def test_mixed_batch_still_http_200(client):
|
def test_mixed_batch_still_http_200(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
_post(test_client, {"data": [_item(fid=1, omsId=1)]})
|
_post(test_client, {"data": [_item(fid=1, omsId=1)]})
|
||||||
resp = _post(
|
resp = _post(
|
||||||
test_client,
|
test_client,
|
||||||
|
|
@ -201,18 +217,34 @@ def test_mixed_batch_still_http_200(client):
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
codes = [r["resultCode"] for r in resp.json()["results"]]
|
codes = [r["resultCode"] for r in resp.json()["results"]]
|
||||||
assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"]
|
assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"]
|
||||||
assert repo.count() == 2
|
assert set(pool.db.contracts) == {1, 2}
|
||||||
|
|
||||||
|
|
||||||
|
# 库故障时 HTTP 503,body 不含 ORA。
|
||||||
def test_repo_unavailable_returns_503(client):
|
def test_repo_unavailable_returns_503(client):
|
||||||
test_client, _ = client
|
test_client, pool = client
|
||||||
|
pool.db.raise_execute = ora_error(
|
||||||
class Boom:
|
12170,
|
||||||
def insert(self, record):
|
"ORA-12170: TNS:Connect timeout occurred at rpa_purchase_contract_data",
|
||||||
raise RepoUnavailable("数据库不可用")
|
)
|
||||||
|
|
||||||
app.dependency_overrides[get_contract_repo] = lambda: Boom()
|
|
||||||
resp = _post(test_client, {"data": [_item()]})
|
resp = _post(test_client, {"data": [_item()]})
|
||||||
assert resp.status_code == 503
|
assert resp.status_code == 503
|
||||||
assert resp.json()["code"] == 503
|
assert resp.json()["code"] == 503
|
||||||
|
assert resp.json()["msg"] == "数据库不可用"
|
||||||
assert resp.json()["results"] == []
|
assert resp.json()["results"] == []
|
||||||
|
assert "ORA-" not in resp.text
|
||||||
|
assert "rpa_purchase_contract_data" not in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# 一批两条合法明细只从池里借一次连接。
|
||||||
|
def test_batch_two_rows_acquires_once(client):
|
||||||
|
test_client, pool = client
|
||||||
|
resp = _post(
|
||||||
|
test_client,
|
||||||
|
{"data": [_item(fid=1, omsId=1), _item(fid=2, omsId=2)]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert [r["resultCode"] for r in resp.json()["results"]] == ["ACCEPTED", "ACCEPTED"]
|
||||||
|
assert pool.acquires == 1
|
||||||
|
many = [c for c in pool.db.calls if c[0] == "executemany"]
|
||||||
|
assert len(many) == 1
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""接口 1 收件契约。内存仓库,不连 Oracle。"""
|
# 接口 1 收件契约。假连接录 SQL,不连 Oracle。
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -8,14 +9,14 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token"
|
os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token"
|
||||||
|
|
||||||
from app.inbound.memory_repo import MemorySalesGoodsRepo
|
from app.main import app, get_pool
|
||||||
from app.inbound.repo import RepoUnavailable
|
from tests.fake_oracle import FakePool, ora_error
|
||||||
from app.main import app, get_repo
|
|
||||||
|
|
||||||
TOKEN = "test-oms-token"
|
TOKEN = "test-oms-token"
|
||||||
PATH = "/api/oms/v1/sales-goods"
|
PATH = "/api/oms/v1/sales-goods"
|
||||||
|
|
||||||
|
|
||||||
|
# 构造一条合法的接口 1 明细,overrides 覆盖字段。
|
||||||
def _item(**overrides):
|
def _item(**overrides):
|
||||||
row = {
|
row = {
|
||||||
"omsId": 2026092801,
|
"omsId": 2026092801,
|
||||||
|
|
@ -40,61 +41,72 @@ def _item(**overrides):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 注入 FakePool,整次请求走同一块假库。
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
repo = MemorySalesGoodsRepo()
|
pool = FakePool()
|
||||||
app.dependency_overrides[get_repo] = lambda: repo
|
app.dependency_overrides[get_pool] = lambda: pool
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
yield test_client, repo
|
yield test_client, pool
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# 带或不带 X-Oms-Token 发 POST。
|
||||||
def _post(client, body, token=TOKEN):
|
def _post(client, body, token=TOKEN):
|
||||||
headers = {"X-Oms-Token": token} if token is not None else {}
|
headers = {"X-Oms-Token": token} if token is not None else {}
|
||||||
return client.post(PATH, json=body, headers=headers)
|
return client.post(PATH, json=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
# 缺 X-Oms-Token 时接口返回 401。
|
||||||
def test_missing_token_returns_401(client):
|
def test_missing_token_returns_401(client):
|
||||||
test_client, _ = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {"data": [_item()]}, token=None)
|
resp = _post(test_client, {"data": [_item()]}, token=None)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["code"] == 401
|
assert body["code"] == 401
|
||||||
|
assert body["msg"] == "身份认证失败"
|
||||||
assert body["results"] == []
|
assert body["results"] == []
|
||||||
|
assert pool.db.audits
|
||||||
|
|
||||||
|
|
||||||
|
# 错误 token 时接口返回 401。
|
||||||
def test_wrong_token_returns_401(client):
|
def test_wrong_token_returns_401(client):
|
||||||
test_client, _ = client
|
test_client, _pool = client
|
||||||
resp = _post(test_client, {"data": [_item()]}, token="bad")
|
resp = _post(test_client, {"data": [_item()]}, token="bad")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert resp.json()["code"] == 401
|
assert resp.json()["code"] == 401
|
||||||
|
|
||||||
|
|
||||||
|
# 缺 data 节点时整包 422,一条都不落。
|
||||||
def test_missing_data_returns_422(client):
|
def test_missing_data_returns_422(client):
|
||||||
test_client, _ = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {})
|
resp = _post(test_client, {})
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
assert resp.json()["code"] == 422
|
assert resp.json()["code"] == 422
|
||||||
assert resp.json()["results"] == []
|
assert resp.json()["results"] == []
|
||||||
|
assert pool.db.sales == {}
|
||||||
|
|
||||||
|
|
||||||
|
# data 空数组时整包 422。
|
||||||
def test_empty_data_returns_422(client):
|
def test_empty_data_returns_422(client):
|
||||||
test_client, _ = client
|
test_client, _pool = client
|
||||||
resp = _post(test_client, {"data": []})
|
resp = _post(test_client, {"data": []})
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
assert resp.json()["code"] == 422
|
assert resp.json()["code"] == 422
|
||||||
|
|
||||||
|
|
||||||
|
# 超过 100 条时整包 422,假库仍空。
|
||||||
def test_over_100_rows_returns_422(client):
|
def test_over_100_rows_returns_422(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
rows = [_item(omsId=i) for i in range(1, 102)]
|
rows = [_item(omsId=i) for i in range(1, 102)]
|
||||||
resp = _post(test_client, {"data": rows})
|
resp = _post(test_client, {"data": rows})
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
assert repo.count() == 0
|
assert pool.db.sales == {}
|
||||||
|
|
||||||
|
|
||||||
|
# 合法一行 ACCEPTED,deal_flag=0,条码和 MDM 按 snake_case 落库。
|
||||||
def test_accepts_valid_row(client):
|
def test_accepts_valid_row(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {"data": [_item()]})
|
resp = _post(test_client, {"data": [_item()]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
|
|
@ -106,26 +118,26 @@ def test_accepts_valid_row(client):
|
||||||
"message": "接收成功,等待RPA处理",
|
"message": "接收成功,等待RPA处理",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
stored = repo.get(2026092801)
|
stored = pool.db.sales[2026092801]
|
||||||
assert stored["deal_flag"] == 0
|
assert stored["deal_flag"] == 0
|
||||||
assert stored["bar_code"] == "06901234567890"
|
assert stored["bar_code"] == "06901234567890"
|
||||||
assert stored["goods_mdm_code"] == "QX0100167583"
|
assert stored["goods_mdm_code"] == "QX0100167583"
|
||||||
|
|
||||||
|
|
||||||
|
# 跨请求同 omsId:DUPLICATE,库里品名不改。
|
||||||
def test_existing_oms_id_is_duplicate_and_not_updated(client):
|
def test_existing_oms_id_is_duplicate_and_not_updated(client):
|
||||||
# 跨请求同 omsId:DUPLICATE,库里品名不改。
|
test_client, pool = client
|
||||||
test_client, repo = client
|
|
||||||
_post(test_client, {"data": [_item()]})
|
_post(test_client, {"data": [_item()]})
|
||||||
resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]})
|
resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
result = resp.json()["results"][0]
|
result = resp.json()["results"][0]
|
||||||
assert result["resultCode"] == "DUPLICATE"
|
assert result["resultCode"] == "DUPLICATE"
|
||||||
assert repo.get(2026092801)["goods_name"] == "阿莫西林胶囊"
|
assert pool.db.sales[2026092801]["goods_name"] == "阿莫西林胶囊"
|
||||||
|
|
||||||
|
|
||||||
|
# 同一 POST 里两条相同 omsId:后一条 CONFLICT,只落第一条。
|
||||||
def test_duplicate_oms_id_in_same_batch_is_conflict(client):
|
def test_duplicate_oms_id_in_same_batch_is_conflict(client):
|
||||||
# 同一 POST 里两条相同 omsId:后一条 CONFLICT,只落第一条。
|
test_client, pool = client
|
||||||
test_client, repo = client
|
|
||||||
resp = _post(
|
resp = _post(
|
||||||
test_client,
|
test_client,
|
||||||
{"data": [_item(), _item(barCode="06901234567891")]},
|
{"data": [_item(), _item(barCode="06901234567891")]},
|
||||||
|
|
@ -135,21 +147,23 @@ def test_duplicate_oms_id_in_same_batch_is_conflict(client):
|
||||||
assert results[0]["resultCode"] == "ACCEPTED"
|
assert results[0]["resultCode"] == "ACCEPTED"
|
||||||
assert results[1]["resultCode"] == "CONFLICT"
|
assert results[1]["resultCode"] == "CONFLICT"
|
||||||
assert results[1]["omsId"] == 2026092801
|
assert results[1]["omsId"] == 2026092801
|
||||||
assert repo.count() == 1
|
assert len(pool.db.sales) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# 缺 MDM 码 REJECTED,不入库。
|
||||||
def test_missing_mdm_is_rejected_and_not_stored(client):
|
def test_missing_mdm_is_rejected_and_not_stored(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
resp = _post(test_client, {"data": [_item(goodsMdmCode="")]})
|
resp = _post(test_client, {"data": [_item(goodsMdmCode="")]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
result = resp.json()["results"][0]
|
result = resp.json()["results"][0]
|
||||||
assert result["resultCode"] == "REJECTED"
|
assert result["resultCode"] == "REJECTED"
|
||||||
assert "goodsMdmCode" in result["message"]
|
assert "goodsMdmCode" in result["message"]
|
||||||
assert repo.count() == 0
|
assert pool.db.sales == {}
|
||||||
|
|
||||||
|
|
||||||
|
# 混批 DUPLICATE + ACCEPTED + REJECTED 时 HTTP 仍 200。
|
||||||
def test_mixed_batch_still_http_200(client):
|
def test_mixed_batch_still_http_200(client):
|
||||||
test_client, repo = client
|
test_client, pool = client
|
||||||
_post(test_client, {"data": [_item(omsId=1)]})
|
_post(test_client, {"data": [_item(omsId=1)]})
|
||||||
resp = _post(
|
resp = _post(
|
||||||
test_client,
|
test_client,
|
||||||
|
|
@ -164,27 +178,72 @@ def test_mixed_batch_still_http_200(client):
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
codes = [r["resultCode"] for r in resp.json()["results"]]
|
codes = [r["resultCode"] for r in resp.json()["results"]]
|
||||||
assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"]
|
assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"]
|
||||||
assert repo.count() == 2
|
assert set(pool.db.sales) == {1, 2}
|
||||||
|
|
||||||
|
|
||||||
|
# JSON 字符串 omsId 不转整数,标 REJECTED。
|
||||||
def test_string_oms_id_is_rejected(client):
|
def test_string_oms_id_is_rejected(client):
|
||||||
# JSON 字符串 "2026092801" 不转整数。
|
test_client, pool = client
|
||||||
test_client, repo = client
|
|
||||||
resp = _post(test_client, {"data": [_item(omsId="2026092801")]})
|
resp = _post(test_client, {"data": [_item(omsId="2026092801")]})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["results"][0]["resultCode"] == "REJECTED"
|
assert resp.json()["results"][0]["resultCode"] == "REJECTED"
|
||||||
assert repo.count() == 0
|
assert pool.db.sales == {}
|
||||||
|
|
||||||
|
|
||||||
def test_repo_unavailable_returns_503(client):
|
# 库故障时 HTTP 503,body 固定「数据库不可用」,不含 ORA 和表名。
|
||||||
test_client, _ = client
|
def test_repo_unavailable_returns_503_without_ora(client):
|
||||||
|
test_client, pool = client
|
||||||
class Boom:
|
pool.db.raise_execute = ora_error(
|
||||||
def insert(self, record):
|
12170,
|
||||||
raise RepoUnavailable("数据库不可用")
|
"ORA-12170: TNS:Connect timeout occurred at rpa_sales_goods_mapping_data",
|
||||||
|
)
|
||||||
app.dependency_overrides[get_repo] = lambda: Boom()
|
|
||||||
resp = _post(test_client, {"data": [_item()]})
|
resp = _post(test_client, {"data": [_item()]})
|
||||||
assert resp.status_code == 503
|
assert resp.status_code == 503
|
||||||
assert resp.json()["code"] == 503
|
body = resp.json()
|
||||||
assert resp.json()["results"] == []
|
assert body["code"] == 503
|
||||||
|
assert body["msg"] == "数据库不可用"
|
||||||
|
assert body["results"] == []
|
||||||
|
text = resp.text
|
||||||
|
assert "ORA-" not in text
|
||||||
|
assert "rpa_sales_goods_mapping_data" not in text
|
||||||
|
|
||||||
|
|
||||||
|
# 一批两条合法明细只从池里借一次连接。
|
||||||
|
def test_batch_two_rows_acquires_once(client):
|
||||||
|
test_client, pool = client
|
||||||
|
resp = _post(
|
||||||
|
test_client,
|
||||||
|
{"data": [_item(omsId=1), _item(omsId=2)]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert [r["resultCode"] for r in resp.json()["results"]] == ["ACCEPTED", "ACCEPTED"]
|
||||||
|
assert pool.acquires == 1
|
||||||
|
many = [c for c in pool.db.calls if c[0] == "executemany"]
|
||||||
|
assert len(many) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# 并发撞上未知唯一约束时不回 DUPLICATE,整包 503。
|
||||||
|
def test_unknown_unique_constraint_is_not_duplicate(client):
|
||||||
|
test_client, pool = client
|
||||||
|
pool.db.collide_sales.add(2026092801)
|
||||||
|
pool.db.collide_constraint = "UK_SOMETHING_ELSE"
|
||||||
|
resp = _post(test_client, {"data": [_item()]})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert resp.json()["msg"] == "数据库不可用"
|
||||||
|
assert "DUPLICATE" not in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# 成功收件写审计一行,入参出参 JSON 里没有 token 明文。
|
||||||
|
def test_success_audit_masks_token(client):
|
||||||
|
test_client, pool = client
|
||||||
|
resp = _post(test_client, {"data": [_item()]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(pool.db.audits) == 1
|
||||||
|
audit = pool.db.audits[0]
|
||||||
|
blob = " ".join(str(v) for v in audit.values())
|
||||||
|
assert TOKEN not in blob
|
||||||
|
assert audit["token_masked"] == "***"
|
||||||
|
assert audit["api_no"] == 1
|
||||||
|
assert audit["http_code"] == 200
|
||||||
|
audit_sql = [c for c in pool.db.calls if c[0] == "execute" and "RPA_OMS_INBOUND_AUDIT" in " ".join(c[1].split()).upper()]
|
||||||
|
assert audit_sql
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# RPA 共用 Oracle 连接层:读 .env、连接池、ORA 码、班次故障审计。
|
||||||
|
#
|
||||||
|
# 各班次和订单中心仓库从这里取连接、认约束名。
|
||||||
|
# 业务表 SQL 仍留在各仓库。班次故障原文只进 rpa_shift_error_audit。
|
||||||
|
|
||||||
|
from .audit import ShiftError, save_shift_errors
|
||||||
|
from .env import job_env_files, load_env, load_job_env
|
||||||
|
from .errors import (
|
||||||
|
RepoUnavailable,
|
||||||
|
is_contract_fid_duplicate,
|
||||||
|
is_sales_oms_id_duplicate,
|
||||||
|
ora_code,
|
||||||
|
ora_constraint,
|
||||||
|
)
|
||||||
|
from .chunked_update import FID_CHUNK_SIZE, update_by_fid_chunks
|
||||||
|
from .pool import OraclePool, create_pool, job_pool, pool_limits
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FID_CHUNK_SIZE",
|
||||||
|
"OraclePool",
|
||||||
|
"RepoUnavailable",
|
||||||
|
"ShiftError",
|
||||||
|
"create_pool",
|
||||||
|
"is_contract_fid_duplicate",
|
||||||
|
"is_sales_oms_id_duplicate",
|
||||||
|
"job_env_files",
|
||||||
|
"job_pool",
|
||||||
|
"load_env",
|
||||||
|
"load_job_env",
|
||||||
|
"ora_code",
|
||||||
|
"ora_constraint",
|
||||||
|
"pool_limits",
|
||||||
|
"save_shift_errors",
|
||||||
|
"update_by_fid_chunks",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
# 加购、合同、返利的未捕获异常原文。
|
||||||
|
#
|
||||||
|
# 短码仍写在业务原因列。本表单独提交,写失败只记日志。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger("rpa_oracle.audit")
|
||||||
|
_ERROR_MAX = 4000
|
||||||
|
|
||||||
|
_INSERT_SQL = """
|
||||||
|
INSERT INTO rpa_shift_error_audit (
|
||||||
|
shift_name, biz_key, error_code, error_text
|
||||||
|
) VALUES (
|
||||||
|
:shift_name, :biz_key, :error_code, :error_text
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# 一条班次故障审计。shift_name 是加购、合同或返利。
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ShiftError:
|
||||||
|
shift_name: str
|
||||||
|
biz_key: str
|
||||||
|
error_code: str
|
||||||
|
error_text: str
|
||||||
|
|
||||||
|
|
||||||
|
# 独立事务写入 rpa_shift_error_audit。空列表不借连接。失败只记日志。
|
||||||
|
def save_shift_errors(pool, items: list[ShiftError]) -> None:
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
binds = [_bind(item) for item in items]
|
||||||
|
try:
|
||||||
|
with pool.acquire() as conn:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany(_INSERT_SQL, binds)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
_rollback(conn)
|
||||||
|
logger.exception("班次故障审计写入失败")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("班次故障审计写入失败")
|
||||||
|
|
||||||
|
|
||||||
|
# 收成插入 bind。原文截到 VARCHAR2(4000)。不绑 fid,留给触发器。
|
||||||
|
def _bind(item: ShiftError) -> dict[str, str]:
|
||||||
|
text = item.error_text or ""
|
||||||
|
if len(text) > _ERROR_MAX:
|
||||||
|
text = text[:_ERROR_MAX]
|
||||||
|
return {
|
||||||
|
"shift_name": item.shift_name,
|
||||||
|
"biz_key": item.biz_key,
|
||||||
|
"error_code": item.error_code,
|
||||||
|
"error_text": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 回滚当前事务。回滚本身失败则忽略。
|
||||||
|
def _rollback(conn) -> None:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""领取用的分段 UPDATE。
|
||||||
|
|
||||||
|
每段 500 个 fid,避开 Oracle 一条 IN 最多 1000 个表达式。
|
||||||
|
调用方持有事务:本模块不提交。任一段失败由调用方整笔回滚。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
FID_CHUNK_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def update_by_fid_chunks(
|
||||||
|
cur,
|
||||||
|
sql_template: str,
|
||||||
|
fids: list[int],
|
||||||
|
binds: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""按每段 500 个 fid 执行同一条 UPDATE。
|
||||||
|
|
||||||
|
sql_template 用 {placeholders} 放本段占位符。各段在当前游标上顺序执行,不提交。
|
||||||
|
空列表不执行。任一段抛错时前面各段已执行但未提交,调用方应整笔回滚。
|
||||||
|
"""
|
||||||
|
if not fids:
|
||||||
|
return
|
||||||
|
extra = dict(binds or {})
|
||||||
|
for start in range(0, len(fids), FID_CHUNK_SIZE):
|
||||||
|
chunk = list(fids[start : start + FID_CHUNK_SIZE])
|
||||||
|
placeholders, fid_binds = _fid_placeholders(chunk)
|
||||||
|
cur.execute(
|
||||||
|
sql_template.format(placeholders=placeholders),
|
||||||
|
{**extra, **fid_binds},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fid_placeholders(fids: list[int]) -> tuple[str, dict[str, int]]:
|
||||||
|
"""把本段 fid 编成 :id0, :id1 占位和 bind。段内从 0 编号。"""
|
||||||
|
fid_binds = {f"id{index}": value for index, value in enumerate(fids)}
|
||||||
|
placeholders = ", ".join(f":id{index}" for index in range(len(fids)))
|
||||||
|
return placeholders, fid_binds
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# 把本机 .env 补进进程环境,不覆盖已经有值的变量。
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# 按顺序读 .env,只填当前为空的变量。
|
||||||
|
#
|
||||||
|
# 文件不存在则跳过。已有非空环境变量不覆盖。值两边成对的单双引号会去掉。
|
||||||
|
# 多个路径时,先读到的非空值留下。
|
||||||
|
def load_env(*paths: Path) -> None:
|
||||||
|
for path in paths:
|
||||||
|
_fill_env(path)
|
||||||
|
|
||||||
|
|
||||||
|
# 该班次要读的 .env:本包,加购再加选品,最后 rpa_oms。
|
||||||
|
def job_env_files(package_dir: Path) -> tuple[Path, ...]:
|
||||||
|
repo = package_dir.resolve().parent
|
||||||
|
files = [package_dir / ".env"]
|
||||||
|
if package_dir.name == "rpa_cart":
|
||||||
|
files.append(repo / "rpa_selection" / ".env")
|
||||||
|
files.append(repo / "rpa_oms" / ".env")
|
||||||
|
return tuple(files)
|
||||||
|
|
||||||
|
|
||||||
|
# 先读班次 .env,空缺再补后面的文件。
|
||||||
|
#
|
||||||
|
# 默认顺序见 job_env_files。paths 给定时只读这一列,供测试注入。
|
||||||
|
# 已有非空环境变量不覆盖。
|
||||||
|
def load_job_env(package_dir: Path, paths: list[Path] | None = None) -> None:
|
||||||
|
load_env(*(paths if paths is not None else job_env_files(package_dir)))
|
||||||
|
|
||||||
|
|
||||||
|
# 把单个 .env 里尚未占用的键写进 os.environ。
|
||||||
|
def _fill_env(path: Path) -> None:
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
||||||
|
value = value[1:-1]
|
||||||
|
if not key or not value:
|
||||||
|
continue
|
||||||
|
current = os.environ.get(key, "")
|
||||||
|
if not current:
|
||||||
|
os.environ[key] = value
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue