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/
|
||||
rpa_contract/screenshots/
|
||||
rpa_rebate/screenshots/
|
||||
rpa_cart/exports/
|
||||
rpa-chrome-profile/
|
||||
**/chrome-profile/
|
||||
.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 入口 / 鉴权 | `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 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 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 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/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/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_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_cart/cart_job.py`、`cart_repo.py`、`cart_match.py` | `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` |
|
||||
| 支付回传 | `rpa_cart/job.py`、`settle.py` | 1 号药城支付按 Excel 落表,不对加购行 |
|
||||
| 返利班查号落表 | `rpa_rebate/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_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_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_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_common.py` | 四个节点共用;改这里四个一起变 |
|
||||
| 返利班查号页面 | `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_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_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` | — |
|
||||
| 节点业务规则(点名一节) | `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。
|
||||
|
||||
运行时只落 Oracle。单测用内存替身,不连库。服务端口 **8001**。
|
||||
运行时只落 Oracle。入站单测用假连接录 SQL,不连库。服务端口 **8001**。连接从 `rpa_oracle` 池借,一个请求一条。
|
||||
|
||||
| 步 | 函数 | 要点 |
|
||||
| --- | --- | --- |
|
||||
| 信封 | `parse_envelope` | 缺 `data` / 空数组 / 超过 100 条:整包 422,一条都不落 |
|
||||
| 逐条校验 | `receive_sales_goods` | 入参 camelCase,落表 snake_case;长度跟 VARCHAR2 |
|
||||
| 鉴权 | `token_ok` | 未配 token 一律失败 |
|
||||
| 落表 | `OracleSalesGoodsRepo.insert` | `fid` 由序列触发器生成,INSERT 不绑这一列 |
|
||||
| 整批校验 | `receive_sales_goods` | 内存 REJECTED / 同批 CONFLICT,再一次查重一次 `executemany` |
|
||||
| 鉴权 | `token_ok` | `X-Oms-Token` 共享密钥,未配一律失败;失败进审计 |
|
||||
| 落表 | `existing_oms_ids` + `insert_many` | `fid` 由序列触发器生成,INSERT 不绑这一列;只认 `uk_rpa_sgmd_oms_id` |
|
||||
| 幂等 | 同 `omsId` | 已存在 → `DUPLICATE`,不更新、不重跑 |
|
||||
| 同批冲突 | 同一 POST 里 `omsId` 再现 | 后一条 `CONFLICT`,只落第一条 |
|
||||
| 字段不过 | `_parse_row` | `REJECTED`,不入库 |
|
||||
| 混批 | `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 一致。
|
||||
|
||||
接口 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 只组装不推送。
|
||||
|
||||
|
|
@ -80,9 +87,9 @@
|
|||
|
||||
| 步 | 函数 | 要点 |
|
||||
| --- | --- | --- |
|
||||
| 领取 | `claim_pending` | `deal_flag` 0→3;中途崩溃停在 3,改回 0 再跑 |
|
||||
| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利 ≥ 4%、效期 ≥ 12 月;同仓只留低价;按仓序拆量 |
|
||||
| 落表 | `build_result` + `save_result` | 成功每仓一行 `status=1`,合计=计划量;`push_status=0` |
|
||||
| 领取 | `claim_pending` | `deal_flag` 0→3;`FOR UPDATE SKIP LOCKED`;中途崩溃停在 3,超过租约(`deal_time` 30 分钟)可再领 |
|
||||
| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利严格大于 4%、效期 ≥ 12 月;同仓多个 sku 都参与拆量,低价先吃;按仓序拆量 |
|
||||
| 落表 | `build_result` + `save_result` | 成功每条拆量一行 `status=1`,合计=计划量;`push_status=0` |
|
||||
| 库存不足 | `KIND_BUSINESS_FAIL` | 两边都搜到但合计不够:`deal_flag=2`,该 `omsId` 不重跑 |
|
||||
| 对端故障 | `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` | 条形码结果对商家全称;同仓留低价 |
|
||||
| 加车 | `run_cart_batch` + 平台 `add_*_cart` | 改数量加入购物车。无 `sku_id`,必须再搜 |
|
||||
| 成功 | `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,不插新行)。
|
||||
|
||||
## 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/` |
|
||||
| S03 登录 | `login_contract_system` | `10007228` / `1` |
|
||||
| S04–S05 起草 | `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
|
||||
| S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
|
||||
| 尽调 | `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
|
||||
| S07 基础 | `fill_contract_basic_info` | 标题 `_expected_title` |
|
||||
| S08 金额日期 | `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` |
|
||||
| S09 货品 | `fill_contract_goods_lines` | 弹窗按操作码搜 |
|
||||
| S10 付款 | `fill_contract_payment_info` | |
|
||||
| S11 PDF | `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF |
|
||||
| S12 提交 | `submit_contract_for_approval` | 成功后**不要等详情页** |
|
||||
| S13 合同号 | `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 |
|
||||
| S02 打开 | `contract_login.py` `open_contract_system` | `http://contract-test.gzmpc.com:8080/` |
|
||||
| S03 登录 | `contract_login.py` `login_contract_system` | 账密只认 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD` |
|
||||
| S04–S05 起草 | `contract_login.py` `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
|
||||
| S06 签约方 | `contract_form.py` `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
|
||||
| 尽调 | `contract_form.py` `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
|
||||
| S07 基础 | `contract_form.py` `fill_contract_basic_info` | 标题 `_expected_title` |
|
||||
| S08 金额日期 | `contract_form.py` `fill_contract_amount_and_dates` | 签订日/开始日用接口 3 三日期,缺则回退 `order_date` |
|
||||
| S09 货品 | `contract_goods.py` `fill_contract_goods_lines` | 弹窗按操作码搜,找不到则本单失败 |
|
||||
| S10 付款 | `contract_form.py` `fill_contract_payment_info` | |
|
||||
| S11 PDF | `contract_upload.py` `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF |
|
||||
| S12 提交 | `contract_submit.py` `submit_contract_for_approval` | 成功后**不要等详情页** |
|
||||
| S13 合同号 | `contract_number.py` `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 |
|
||||
|
||||
成功路径默认不截图(`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`,输入框回显不算 |
|
||||
| 录协议 | `recorder` | 搜到后总单 → 细单新增(按货品ID,不做 Excel)→ 送审;送审成功才插 `actionCode=2` |
|
||||
| 落表 | `build_found` + `save_group` | 起草行 `contract_status=2`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带总单/细单号;`push_status=0` |
|
||||
| 搜不到 | `build_not_found` | 戳改回 `合同班`;表 3 改回 `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` | 表 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`。
|
||||
|
||||
不推接口 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_HEADER` | RPA | `rebateOrderNo` | 已拆:搜到后填映射表默认值 → 保存拿总单号 |
|
||||
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:勾已有总单,按 `返利流程.md` 填现行字段 → 保存拿协议总单ID |
|
||||
| `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel |
|
||||
| `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 |
|
||||
| `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`;禁止为「显得全面」跑整包再去连真库。
|
||||
- 接口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`。
|
||||
- 选品:点名 `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`。
|
||||
|
|
@ -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_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`。
|
||||
- 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。
|
||||
- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。
|
||||
- 接口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`;步骤门闸点名 `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_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
|
||||
| `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 |
|
||||
| `rpa_cart/` | 加购已接;1 号药城支付落表。表 2 → 口 2 仍未推 |
|
||||
| `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 |
|
||||
| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号 → 总单 → 细单新增 → 送审同一班 |
|
||||
|
||||
|
|
@ -36,6 +36,18 @@
|
|||
|
||||
新接口只进 `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)。
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
| `API鉴权.doc` | 调订单中心时的 Token;接口 1、3 入站鉴权由本方定 |
|
||||
| `下一会话任务_建表.md` | 建表任务说明(已完成) |
|
||||
| `合同系统录入流程.md` | 合同班合单、尽调、提交、回写;页面步骤指向 `rpa_contract` |
|
||||
| `../rpa_rebate/返利流程.md` | 返利页面现行(总单/细单/送审)。与映射表冲突听这份 |
|
||||
| `../rpa_rebate/现行模式.md` | 返利班相对旧稿改了什么、下一会话未做 |
|
||||
| `大批量跑缺口.md` | 四条班次相对大批量现场还缺什么(口 2/4 HTTP、调度、崩溃戳、支付/M5) |
|
||||
| `写死测试配置整改清单.md` | 上正式前要拆的测试站、测试账号和干跑回退 |
|
||||
|
||||
接口字段、示例报文仍以 Excel 为准(当前 `Downloads/RPA与订单中心接口设计_20260922.xlsx`)。Excel 与口径冲突时,作业语义听口径。
|
||||
|
||||
|
|
@ -22,7 +26,7 @@
|
|||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `合同返利系统字段映射_20260911.xlsx` | 返利总单默认值 |
|
||||
| `合同返利系统字段映射_20260911.xlsx` | 返利旧默认值;现行字段以 `rpa_rebate/返利流程.md` 为准 |
|
||||
| `采购平台选品条件优先项_20260902.txt` | 选品规则原文 |
|
||||
| `【项目方案】福建洁达采购合同RPA自动化录入需求_v20260924.docx` | 页面操作参考 |
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
|
||||
- `Downloads/RPA与订单中心接口设计_20260922.xlsx`(以当时最新一份为准)
|
||||
- `docs/API鉴权.doc`
|
||||
- `docs/合同返利系统字段映射_20260911.xlsx`(返利总单默认值)
|
||||
- `docs/合同返利系统字段映射_20260911.xlsx`(返利旧默认值,对照用)
|
||||
- `rpa_rebate/返利流程.md`(返利页面现行;与映射表冲突听这份)
|
||||
- `rpa_selection/` 既有规则
|
||||
|
||||
---
|
||||
|
|
@ -135,7 +136,7 @@
|
|||
- 搜到:先回合同成功,再录协议
|
||||
- 搜不到:本班不录协议、不重开合同,下一班再搜
|
||||
|
||||
总单默认值跟映射表(计划员用订单中心给的,承付「下月 N 号结」,协议结束当天等)。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。
|
||||
总单/细单现行字段以 `rpa_rebate/返利流程.md` 为准:计划员现阶段「廖建琦」(后期改回订单中心),联系人「苏文辉」,结算「电汇」,承付「下月 N 号结」参数 `25`,协议开始/结束当天。细单走页面新增(按 `goods_id` 搜),不做 Excel。货源细单ID 本期不填。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。映射表作对照,冲突听返利流程。
|
||||
|
||||
### 5.3 协议「成功」
|
||||
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ CREATE TABLE rpa_contract_agreement_result (
|
|||
update_user_id NUMBER(19,0),
|
||||
update_user_name VARCHAR2(100 CHAR),
|
||||
org_code VARCHAR2(10),
|
||||
process_status NUMBER(1,0) DEFAULT 0 NOT NULL,
|
||||
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_name 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) | 同 | 无 |
|
||||
| 19 | 更新人名称 | update_user_name | varchar2(100 char) | 同 | 无 |
|
||||
| 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 个月。库存不再是过滤项。
|
||||
2. 按四级仓序(同仓低价、再高库存)排序。
|
||||
3. 同一平台同一仓只留 1 条候选。
|
||||
3. 同一平台同一仓的多个 sku 都参与拆量,不再只留低价那条。
|
||||
4. 按序贪心拆量,直到计划采购量凑齐。
|
||||
5. 凑齐:每个仓一行写入表 2,`status=1`,数量是该仓拆到的件数,合计等于计划量。
|
||||
5. 凑齐:每条拆量一行写入表 2,`status=1`,数量是该条拆到的件数,合计等于计划量。
|
||||
6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。
|
||||
|
||||
完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。
|
||||
|
|
@ -33,7 +33,6 @@
|
|||
- 接口 2 推送(`push_status` 仍为 0)
|
||||
- 批准文号搜索、商家名模糊匹配
|
||||
- 中包装 / 起订量
|
||||
- 同一仓多 SKU 拆成两行
|
||||
- 改表结构、改接口 1
|
||||
|
||||
---
|
||||
|
|
@ -94,11 +93,11 @@
|
|||
- `EXPIRY_UNPARSEABLE` / `EXPIRY_LT_12M`
|
||||
- `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 排序
|
||||
|
||||
|
|
@ -166,8 +165,8 @@ for candidate in sorted_passed:
|
|||
| `supplier_name` | `vendor_raw` |
|
||||
| `bar_code` / `org_code` | 原样 |
|
||||
| `purchase_price` | 该候选平台价,四位小数 |
|
||||
| `purchase_qty` | 该仓 `take`,**不是**计划全量 |
|
||||
| `purchase_total` | 单价 × 该仓数量,四位小数 |
|
||||
| `purchase_qty` | 该条 `take`,**不是**计划全量 |
|
||||
| `purchase_total` | 单价 × 该条数量,四位小数 |
|
||||
| `status` | 1 |
|
||||
| `fail_reason` | null |
|
||||
| `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)。
|
||||
|
||||
业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0、以及被 `SAME_WAREHOUSE_NOT_BEST` 折叠掉但仍算「该仓有过过滤候选」——以折叠**之前**过过滤的集合为准):
|
||||
业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0。以过过滤的集合为准):
|
||||
|
||||
1. `NO_BAR_CODE` / `NO_SALE_PRICE` / `NO_QTY`:沿用现有中文
|
||||
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` 只留给单测过渡,班次不再调用 |
|
||||
| `job.py` | 先收集各平台搜索结果,再 `run_selection` |
|
||||
| `persist.py` | 吃整单结果:成功按拆量多行;`multi_platform_flag` 按平台种类数;失败原因 5% → 4%,并增加库存不足 |
|
||||
|
|
@ -234,7 +233,7 @@ class SelectionResult:
|
|||
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 决策」去猜:
|
||||
|
||||
|
|
@ -257,7 +256,7 @@ class SelectionResult:
|
|||
- 湖南壹号、甘肃壹号云视为非自营
|
||||
- 近仓 3、次仓 20、计划 10 → 近仓 3 + 次仓 7
|
||||
- 合计 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 件拆
|
||||
|
||||
班次 / 落表:
|
||||
|
|
|
|||
|
|
@ -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` 拆两仓、两平台要加齐。
|
||||
|
||||
成功不插新行,只把 `update_user_name` 改成 `加购班`。加不进去(登录挂、库存没了、中包装加不进、对不上仓)改该行 `status=2`,写失败原因。领取时先盖 `加购中`;中途崩溃停在这一戳,改回 `系统管理员` 再跑。
|
||||
成功不插新行,只把 `update_user_name` 改成 `加购班`。加不进去(登录挂、库存没了、中包装加不进、对不上仓)改该行 `status=2`,写失败原因。领取时先盖 `加购中`;中途崩溃停在这一戳。`update_time` 超过 30 分钟的「加购中」下一班会再领;未过期的需改回 `系统管理员` 再跑。
|
||||
|
||||
不推订单中心,不清空购物车,不碰合同。表 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`。加购班跑完之前不要组装推送。
|
||||
|
||||
## 运维
|
||||
|
||||
### 怎么跑
|
||||
|
||||
```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 号药城支付落表
|
||||
|
||||
从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。
|
||||
|
|
@ -44,9 +86,6 @@ cd rpa_cart
|
|||
uv sync --python 3.12
|
||||
# 账号用 rpa_selection/.env 的 YAOEX_*,库用 RPA_ORACLE_*
|
||||
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),不是平台数据,给订单中心防重传。
|
||||
|
|
@ -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 python main.py cart
|
||||
# 国庆当天可能没单,用 9-30 有数
|
||||
uv run python main.py --dry-run --day 2026-09-30 --days 0
|
||||
uv run python main.py --day 2026-09-30 --days 0
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
看控制台 `inserted / skipped_dup / unmatched`。同一订单号再跑应 `skipped_dup` 上升。
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""加车结果。HTTP 客户端把商城回包收成这三种。"""
|
||||
# 加车结果。HTTP 客户端把商城回包收成这三种。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -8,12 +9,14 @@ KIND_BUSINESS = "business"
|
|||
KIND_SYSTEM = "system"
|
||||
|
||||
|
||||
# 一次加车的结果:成功、业务失败(库存/中包装)或系统失败(登录、返回异常)。
|
||||
@dataclass
|
||||
class AddOutcome:
|
||||
kind: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
# 按商城回文区分成功、库存/中包装等业务失败,以及看不懂的系统失败。
|
||||
def classify_mall_message(text: str) -> AddOutcome:
|
||||
msg = (text or "").strip()
|
||||
if not msg:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
"""加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。成功盖章,失败改 status=2。"""
|
||||
# 加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。
|
||||
#
|
||||
# 成功盖章,失败改 status=2。崩在「加购中」:超过租约可再领,未过期需改回「系统管理员」。
|
||||
# 查询失败原因列只写 QUERY_FAILED / LOGIN_FAILED / ADD_FAILED。原文进审计表,会话结束后另写。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
|
|
@ -10,13 +17,19 @@ from cart_add import ( # noqa: F401
|
|||
KIND_SYSTEM,
|
||||
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 rpa_oracle import ShiftError
|
||||
|
||||
Searcher = Callable[[str], list[CartOffer]]
|
||||
Adder = Callable[[CartOffer, int], AddOutcome]
|
||||
CartReader = Callable[[str, str], int]
|
||||
_RETRY_CODES = ("LOGIN_FAILED", "QUERY_FAILED")
|
||||
logger = logging.getLogger("rpa_cart")
|
||||
INCOMPLETE_MARK = "未加齐"
|
||||
|
||||
|
||||
# 一个加购班次的领取和盖章计数。
|
||||
@dataclass
|
||||
class CartSummary:
|
||||
claimed: int = 0
|
||||
|
|
@ -24,6 +37,14 @@ class CartSummary:
|
|||
failed: int = 0
|
||||
|
||||
|
||||
def _session(repo):
|
||||
"""有 session 就借一条,没有就空上下文。商城调用必须在这个块外面。"""
|
||||
session = getattr(repo, "session", None)
|
||||
if session is None:
|
||||
return nullcontext()
|
||||
return session()
|
||||
|
||||
|
||||
def run_cart_batch(
|
||||
repo,
|
||||
*,
|
||||
|
|
@ -32,65 +53,143 @@ def run_cart_batch(
|
|||
login_errors: dict[int, str],
|
||||
limit: int | None = None,
|
||||
platform_types: list[int] | None = None,
|
||||
cart_readers: dict[int, CartReader] | None = None,
|
||||
) -> CartSummary:
|
||||
"""领取待加购行,按行搜仓、加车、盖章。
|
||||
|
||||
session 只包领取和每一组的盖章。搜索和加车在连接归还之后跑。一组盖完再处理下一组。
|
||||
登录失败和查询失败把戳改回系统管理员。购物车里已有不少于本行的数量则直接成功,不加第二份。
|
||||
limit 交给领取层。
|
||||
"""
|
||||
audits: list[ShiftError] = []
|
||||
readers = cart_readers or {}
|
||||
with _session(repo):
|
||||
rows = repo.claim_pending(limit=limit, platform_types=platform_types)
|
||||
summary = CartSummary(claimed=len(rows))
|
||||
for row in rows:
|
||||
reason = _add_one(row, searchers, adders, login_errors)
|
||||
for oms_id, group in _by_oms(rows).items():
|
||||
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:
|
||||
repo.mark_success(row.fid)
|
||||
summary.added += 1
|
||||
continue
|
||||
if incomplete:
|
||||
reason = _incomplete_reason(reason)
|
||||
if _retryable(code, reason):
|
||||
repo.mark_release(row.fid, reason)
|
||||
else:
|
||||
repo.mark_fail(row.fid, reason)
|
||||
summary.failed += 1
|
||||
if code and text:
|
||||
audits.append(ShiftError("加购", str(row.fid), code, text))
|
||||
repo.save_shift_errors(audits)
|
||||
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(
|
||||
row: CartRow,
|
||||
searchers: dict[int, Searcher],
|
||||
adders: dict[int, Adder],
|
||||
login_errors: dict[int, str],
|
||||
) -> str | None:
|
||||
cart_readers: dict[int, CartReader],
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""对一行查购物车、搜仓并加车。
|
||||
|
||||
返回 (失败原因, 审计短码, 原文)。成功三项都是 None。
|
||||
购物车里该条形码和供应商的数量已经不少于本行时直接成功,多出来的数量不加第二份。
|
||||
业务原因(对不上仓、库存、商城回的业务失败)不进审计。没有原文也不进。
|
||||
"""
|
||||
platform = row.platform_type
|
||||
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)
|
||||
if searcher is None:
|
||||
return _system("未登录该平台")
|
||||
return _system("LOGIN_FAILED"), None, None
|
||||
adder = adders.get(platform)
|
||||
if adder is None:
|
||||
return _system("未配置加车")
|
||||
return _system("ADD_FAILED"), None, None
|
||||
bar_code = (row.bar_code or "").strip()
|
||||
if not bar_code:
|
||||
return "缺少条形码"
|
||||
return "缺少条形码", None, None
|
||||
qty = int(row.purchase_qty)
|
||||
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:
|
||||
offers = searcher(bar_code)
|
||||
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)
|
||||
if offer is None:
|
||||
return "未找到对应仓"
|
||||
return "未找到对应仓", None, None
|
||||
if offer.stock is not None and offer.stock < qty:
|
||||
return "库存不足"
|
||||
pack = middle_pack(offer)
|
||||
if not qty_fits_pack(qty, pack):
|
||||
return f"中包装加不进:数量须为{pack}的倍数"
|
||||
return "库存不足", None, None
|
||||
try:
|
||||
outcome = adder(offer, qty)
|
||||
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:
|
||||
return None
|
||||
return None, None, None
|
||||
reason = (outcome.reason or "加购失败").strip()
|
||||
if outcome.kind == KIND_SYSTEM:
|
||||
return _system(reason)
|
||||
return clip_reason(reason)
|
||||
return _system(reason), None, None
|
||||
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:
|
||||
text = (detail or "未知错误").strip()
|
||||
if text.startswith("系统故障"):
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
"""按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。"""
|
||||
# 按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
SELF_OPERATED_PREFIX = "[自营]"
|
||||
PACK_KEYS = ("middlePackNum", "midPackQty", "packNum", "packQty")
|
||||
|
||||
|
||||
# 再搜到的一条报价:商家全称、库存、价格。加车前靠它对上表 2 的供应商。
|
||||
@dataclass
|
||||
class CartOffer:
|
||||
sku_id: str
|
||||
|
|
@ -18,6 +19,7 @@ class CartOffer:
|
|||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# 去掉「[自营]」前缀和多余空白,用来对商家全称。
|
||||
def normalize_supplier(raw: str) -> str:
|
||||
text = (raw or "").replace("\u3000", " ").strip()
|
||||
if text.startswith(SELF_OPERATED_PREFIX):
|
||||
|
|
@ -25,6 +27,7 @@ def normalize_supplier(raw: str) -> str:
|
|||
return " ".join(text.split())
|
||||
|
||||
|
||||
# 条形码结果里对上商家全称。同仓留低价,价格相同留库存多的。对不上返回 None。
|
||||
def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None:
|
||||
target = normalize_supplier(supplier_name)
|
||||
if not target:
|
||||
|
|
@ -40,23 +43,7 @@ def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None
|
|||
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]:
|
||||
price = offer.price
|
||||
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(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 collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
STATUS_OK = 1
|
||||
|
|
@ -15,16 +21,13 @@ STAMP_SELECTION = "系统管理员"
|
|||
STAMP_CLAIMING = "加购中"
|
||||
STAMP_DONE = "加购班"
|
||||
FAIL_REASON_MAX = 500
|
||||
LEASE_MINUTES = 30
|
||||
|
||||
_CLAIM_SQL = """
|
||||
UPDATE rpa_cart_settlement_goods_data
|
||||
SET update_user_name = :stamp,
|
||||
update_time = SYSDATE
|
||||
WHERE fid = :fid
|
||||
AND operate_type = 1
|
||||
AND status = 1
|
||||
AND push_status = 0
|
||||
AND NVL(update_user_name, :selection_stamp) = :selection_stamp
|
||||
WHERE fid IN ({placeholders})
|
||||
"""
|
||||
|
||||
_SUCCESS_SQL = """
|
||||
|
|
@ -46,7 +49,17 @@ WHERE fid = :fid
|
|||
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
|
||||
class CartRow:
|
||||
fid: int
|
||||
|
|
@ -62,6 +75,7 @@ class CartRow:
|
|||
operate_type: int = OPERATE_TYPE_ADD_CART
|
||||
|
||||
|
||||
# 把失败原因截到 FAIL_REASON_MAX 字,超长末尾加省略号。
|
||||
def clip_reason(text: str) -> str:
|
||||
text = (text or "").strip()
|
||||
if len(text) <= FAIL_REASON_MAX:
|
||||
|
|
@ -69,16 +83,26 @@ def clip_reason(text: str) -> str:
|
|||
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
|
||||
return (
|
||||
int(row.get("operate_type") or 0) == OPERATE_TYPE_ADD_CART
|
||||
and int(row.get("status") or 0) == STATUS_OK
|
||||
and int(row.get("push_status") or 0) == PUSH_NOT_SENT
|
||||
and stamp == STAMP_SELECTION
|
||||
)
|
||||
if (
|
||||
int(row.get("operate_type") or 0) != OPERATE_TYPE_ADD_CART
|
||||
or int(row.get("status") or 0) != STATUS_OK
|
||||
or int(row.get("push_status") or 0) != PUSH_NOT_SENT
|
||||
):
|
||||
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:
|
||||
qty = data.get("purchase_qty")
|
||||
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]]):
|
||||
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:
|
||||
# 回滚当前事务。回滚本身失败则忽略。
|
||||
def _rollback(conn) -> None:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
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:
|
||||
cols = [item[0].lower() for item in cur.description]
|
||||
return _row_from_dict(dict(zip(cols, rec)))
|
||||
|
||||
|
||||
def _pending_sql(
|
||||
# 拼领取 SELECT:系统管理员或过期加购中,末尾 FOR UPDATE SKIP LOCKED。
|
||||
def _pending_select_sql(
|
||||
*, limit: int | None, platform_types: list[int] | None
|
||||
) -> 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 = [
|
||||
"operate_type = 1",
|
||||
"status = 1",
|
||||
"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 not platform_types:
|
||||
|
|
@ -242,4 +170,201 @@ ORDER BY oms_id, fid
|
|||
if limit is not None:
|
||||
params["limit"] = int(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
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
import selection_path # noqa: F401 先把 rpa_selection 放进导入路径
|
||||
from cart_match import CartOffer
|
||||
from yaoex_login import SELECTION_ROOT
|
||||
|
||||
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
|
||||
from search_yaoex import map_yaoex_products, query_yaoex_products
|
||||
from search_ybm import map_ybm_products, query_ybm_products
|
||||
|
||||
|
||||
# 按条形码搜 1 号药城,结果转成 CartOffer 并带上原始商品字段。
|
||||
def search_yaoex_offers(
|
||||
auth,
|
||||
query_key: str,
|
||||
|
|
@ -42,6 +40,7 @@ def search_yaoex_offers(
|
|||
return offers
|
||||
|
||||
|
||||
# 按条形码搜药帮忙,结果转成 CartOffer 并带上原始商品字段。
|
||||
def search_ybm_offers(
|
||||
cookies: dict[str, str],
|
||||
query_key: str,
|
||||
|
|
@ -70,6 +69,7 @@ def search_ybm_offers(
|
|||
return offers
|
||||
|
||||
|
||||
# 从药帮忙搜索行抽出商品原始字段,补上 skuId。
|
||||
def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]:
|
||||
product = row.get("productInfo")
|
||||
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
|
||||
|
||||
|
||||
# 把选品候选加上原始字段收成加购用的 CartOffer。
|
||||
def _from_candidate(candidate, raw: dict[str, Any]) -> CartOffer:
|
||||
extra = dict(candidate.extra)
|
||||
extra["raw"] = raw
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""1 号药城加入购物车。"""
|
||||
# 1 号药城加入购物车。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
|
@ -16,6 +17,7 @@ GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
|
|||
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:
|
||||
ids = _ids(offer)
|
||||
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()
|
||||
|
||||
|
||||
# 从报价里取出加车要的 sku、spu 和商家编码。
|
||||
def _ids(offer: CartOffer) -> dict[str, str]:
|
||||
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
|
||||
if not isinstance(raw, dict):
|
||||
|
|
@ -58,6 +61,7 @@ def _ids(offer: CartOffer) -> dict[str, str]:
|
|||
return {"skuId": sku, "spuCode": spu, "sellerCode": seller}
|
||||
|
||||
|
||||
# 拼 1 号药城表单:带上登录 token,再叠上加车字段。
|
||||
def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = {
|
||||
"traderName": "yaoex_pc",
|
||||
|
|
@ -75,6 +79,7 @@ def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
|
|||
return payload
|
||||
|
||||
|
||||
# 1 号药城加车请求头。来源页是购物车。
|
||||
def _headers() -> dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
|
@ -87,6 +92,7 @@ def _headers() -> dict[str, str]:
|
|||
}
|
||||
|
||||
|
||||
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
|
||||
def _outcome_from_json(payload: object) -> AddOutcome:
|
||||
if not isinstance(payload, dict):
|
||||
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""药帮忙加入购物车。"""
|
||||
# 药帮忙加入购物车。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
|
|
@ -12,6 +13,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|||
YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product"
|
||||
|
||||
|
||||
# 按再搜到的 sku 把数量加入药帮忙购物车。没有 sku 当系统失败。
|
||||
def add_ybm_cart(
|
||||
cookies: dict[str, str],
|
||||
offer: CartOffer,
|
||||
|
|
@ -44,6 +46,7 @@ def add_ybm_cart(
|
|||
client.close()
|
||||
|
||||
|
||||
# 从报价里取药帮忙加车用的 skuId。
|
||||
def _sku_id(offer: CartOffer) -> str:
|
||||
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
|
||||
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 "")
|
||||
|
||||
|
||||
# 药帮忙加车请求头。
|
||||
def _headers() -> dict[str, str]:
|
||||
return {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
|
|
@ -65,6 +69,7 @@ def _headers() -> dict[str, str]:
|
|||
}
|
||||
|
||||
|
||||
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
|
||||
def _outcome_from_json(payload: object) -> AddOutcome:
|
||||
if not isinstance(payload, dict):
|
||||
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 dataclasses import dataclass
|
||||
|
|
@ -6,6 +7,7 @@ from dataclasses import dataclass
|
|||
from settle import MallLine, match_pay_lines
|
||||
|
||||
|
||||
# 一次支付落表的计数:写入、状态跳过、订单号重复、对不上。
|
||||
@dataclass
|
||||
class PaySummary:
|
||||
source_lines: int = 0
|
||||
|
|
@ -15,11 +17,13 @@ class PaySummary:
|
|||
unmatched: int = 0
|
||||
unmatched_reasons: dict[str, int] | None = None
|
||||
|
||||
# 未对上的原因计数缺省为空字典,避免调用方再判 None。
|
||||
def __post_init__(self) -> None:
|
||||
if self.unmatched_reasons is None:
|
||||
self.unmatched_reasons = {}
|
||||
|
||||
|
||||
# 把 1 号药城已支付行写入表 2 的支付行。不对加购行。dry_run 只统计不落库。
|
||||
def run_yaoex_pay(
|
||||
repo,
|
||||
lines: list[MallLine],
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
"""加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。"""
|
||||
# 加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from db import RepoUnavailable
|
||||
from rpa_oracle import RepoUnavailable
|
||||
from job import run_yaoex_pay
|
||||
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_login import (
|
||||
BrowserRuntime,
|
||||
|
|
@ -21,10 +21,33 @@ from yaoex_login import (
|
|||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
EXPORT_DIR = PROJECT_ROOT / "exports" / "yaoex"
|
||||
YAOEX_PLATFORM_TYPE = 66
|
||||
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:
|
||||
argv = list(argv) if argv is not None else None
|
||||
if argv is None:
|
||||
|
|
@ -43,26 +66,24 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
return args
|
||||
|
||||
|
||||
# 支付落表没有额外参数:登录、导出当天、写表 2。
|
||||
def _parse_pay_args(argv: list[str]) -> argparse.Namespace:
|
||||
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)
|
||||
parser = argparse.ArgumentParser(description="1 号药城支付落表:登录、导出当天、解析、写库")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _parse_cart_args(argv: list[str]) -> argparse.Namespace:
|
||||
"""解析加购参数:领取条数、平台、是否忽略登录缓存。
|
||||
|
||||
不传 --limit 时用 20,不再把空值当成全部领走。
|
||||
"""
|
||||
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(
|
||||
"--platform",
|
||||
choices=["all", "yaoex", "ybm"],
|
||||
|
|
@ -70,32 +91,25 @@ def _parse_cart_args(argv: list[str]) -> argparse.Namespace:
|
|||
help="all=两平台都跑",
|
||||
)
|
||||
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("--ybm-cookies", type=Path, default=None)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# 入口。cart 子命令加购,否则跑 1 号药城支付落表。
|
||||
def main(argv: list[str] | None = None, repo=None) -> None:
|
||||
args = parse_args(argv)
|
||||
if args.command == "cart":
|
||||
main_cart(args, repo=repo)
|
||||
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()
|
||||
xls_path = args.xls
|
||||
tmp_xls: Path | None = None
|
||||
|
||||
if xls_path is None:
|
||||
try:
|
||||
auth = resolve_yaoex_auth(
|
||||
secrets_path=args.secrets,
|
||||
auth_path=args.yaoex_auth,
|
||||
fresh=args.fresh_login,
|
||||
)
|
||||
auth = resolve_yaoex_auth()
|
||||
token = auth.token
|
||||
print("1号药城认证就绪。")
|
||||
except SystemExit:
|
||||
|
|
@ -103,14 +117,13 @@ def main_pay(args: argparse.Namespace, repo=None) -> None:
|
|||
except Exception as exc:
|
||||
print(f"1号药城登录失败:{exc}")
|
||||
raise SystemExit(2) from exc
|
||||
end = date.fromisoformat(args.day) if args.day else date.today()
|
||||
begin, end_ts = day_range(end, args.days)
|
||||
end = date.today()
|
||||
begin, end_ts = day_range(end, 0)
|
||||
print(f"导出 {begin} ~ {end_ts}")
|
||||
content = export_orders(token, begin=begin, end=end_ts)
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="yaoex_pay_"))
|
||||
tmp_xls = tmp_dir / "orders.xls"
|
||||
tmp_xls.write_bytes(content)
|
||||
xls_path = tmp_xls
|
||||
xls_path = pay_export_path(begin, end_ts, datetime.now())
|
||||
xls_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
xls_path.write_bytes(content)
|
||||
print(f"导出文件 {xls_path} ({len(content)} bytes)")
|
||||
|
||||
lines = parse_order_xls(xls_path)
|
||||
|
|
@ -120,13 +133,10 @@ def main_pay(args: argparse.Namespace, repo=None) -> None:
|
|||
try:
|
||||
repo = OraclePayRepo.from_env()
|
||||
except RepoUnavailable as exc:
|
||||
if not args.dry_run:
|
||||
print(f"数据库不可用:{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(
|
||||
"落表 "
|
||||
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:
|
||||
from cart_job import run_cart_batch
|
||||
from cart_repo import OracleCartRepo
|
||||
|
|
@ -168,7 +179,6 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
|
|||
if want_yaoex:
|
||||
try:
|
||||
yaoex_auth = resolve_yaoex_auth(
|
||||
secrets_path=args.secrets,
|
||||
auth_path=args.yaoex_auth,
|
||||
fresh=args.fresh_login,
|
||||
runtime=runtime,
|
||||
|
|
@ -182,7 +192,6 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
|
|||
if want_ybm:
|
||||
try:
|
||||
ybm_cookies = resolve_ybm_cookies(
|
||||
secrets_path=args.secrets,
|
||||
cookie_path=args.ybm_cookies,
|
||||
fresh=args.fresh_login,
|
||||
runtime=runtime,
|
||||
|
|
@ -200,11 +209,13 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
|
|||
|
||||
if yaoex_auth is not None:
|
||||
|
||||
# 用本班已登录的 1 号药城会话按条形码再搜。
|
||||
def search_yaoex(query_key: str):
|
||||
return search_yaoex_offers(
|
||||
yaoex_auth, query_key, session=yaoex_session, cache=yaoex_cache
|
||||
)
|
||||
|
||||
# 把对上的仓按数量加入 1 号药城购物车。
|
||||
def add_yaoex(offer, qty: int):
|
||||
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:
|
||||
|
||||
# 用本班已登录的药帮忙会话按条形码再搜。
|
||||
def search_ybm(query_key: str):
|
||||
return search_ybm_offers(
|
||||
ybm_cookies, query_key, session=ybm_session, cache=ybm_cache
|
||||
)
|
||||
|
||||
# 把对上的仓按数量加入药帮忙购物车。
|
||||
def add_ybm(offer, qty: int):
|
||||
return add_ybm_cart(ybm_cookies, offer, qty, session=ybm_session)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""解析 1 号药城「订单明细」xls。"""
|
||||
# 解析 1 号药城「订单明细」xls。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
|
@ -24,6 +25,7 @@ _REQUIRED = {
|
|||
}
|
||||
|
||||
|
||||
# 读 1 号药城订单明细表。缺列就失败。订单号为空的行丢掉。
|
||||
def parse_order_xls(path: str | Path) -> list[MallLine]:
|
||||
book = xlrd.open_workbook(str(path))
|
||||
sheet = book.sheet_by_index(0)
|
||||
|
|
@ -57,6 +59,7 @@ def parse_order_xls(path: str | Path) -> list[MallLine]:
|
|||
return lines
|
||||
|
||||
|
||||
# 单元格收成字符串。整数浮点去掉小数点,避免订单号变成 123.0。
|
||||
def _text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
|
@ -65,6 +68,7 @@ def _text(value: object) -> str:
|
|||
return str(value).strip()
|
||||
|
||||
|
||||
# 条码只留第一段。逗号后的附加码丢掉,末尾 .0 去掉。
|
||||
def _barcode(value: object) -> str:
|
||||
text = _text(value)
|
||||
if "," in text:
|
||||
|
|
@ -75,6 +79,7 @@ def _barcode(value: object) -> str:
|
|||
return text
|
||||
|
||||
|
||||
# 单元格收成金额。空值当 0。
|
||||
def _decimal(value: object) -> Decimal:
|
||||
if value is None or value == "":
|
||||
return Decimal("0")
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ dev = [
|
|||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
pythonpath = [".", "..", "../rpa_selection"]
|
||||
testpaths = ["tests"]
|
||||
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 collections.abc import Callable
|
||||
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
|
||||
|
||||
_SELECT_PAY_ORDER_NOS = """
|
||||
|
|
@ -48,50 +52,70 @@ _PAY_BIND_KEYS = (
|
|||
)
|
||||
|
||||
|
||||
class EmptyPayRepo:
|
||||
def load_existing_pay_order_nos(self) -> set[str]:
|
||||
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:
|
||||
# 回滚当前事务。回滚本身失败则忽略。
|
||||
def _rollback(conn) -> None:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
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
|
||||
|
||||
import uuid
|
||||
|
|
@ -13,14 +14,17 @@ UNASSIGNED_OMS_ID = 0
|
|||
PAID_STATUSES = {"待发货", "待收货", "已完成"}
|
||||
|
||||
|
||||
# 金额收到四位小数,四舍五入。接口 2 组包也按这个精度。
|
||||
def money4(value: object) -> Decimal:
|
||||
return Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
# 生成支付明细号。表 2 用它区分同一订单下的不同行。
|
||||
def new_pay_dtl_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
# 1 号药城导出的一行订单明细。只取已支付状态落表。
|
||||
@dataclass(frozen=True)
|
||||
class MallLine:
|
||||
order_no: str
|
||||
|
|
@ -37,6 +41,7 @@ class MallLine:
|
|||
order_paid: Decimal
|
||||
|
||||
|
||||
# 支付匹配结果:可落库的行、对不上的行、重复订单、状态未付。
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
rows: list[dict[str, Any]]
|
||||
|
|
@ -45,6 +50,7 @@ class MatchResult:
|
|||
skipped_status: list[MallLine]
|
||||
|
||||
|
||||
# 筛出待发货、待收货、已完成的行。同一支付订单号已落过的跳过。条码超长不对上。
|
||||
def match_pay_lines(
|
||||
lines: list[MallLine],
|
||||
existing_order_nos: set[str],
|
||||
|
|
@ -64,6 +70,7 @@ def match_pay_lines(
|
|||
return result
|
||||
|
||||
|
||||
# 把一条已支付明细收成表 2 支付行。operate_type=2,oms_id 先填 0,不改加购行。
|
||||
def build_pay_row(line: MallLine) -> dict[str, Any]:
|
||||
price = money4(line.pay_price)
|
||||
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_match import CartOffer
|
||||
from cart_repo import STAMP_DONE, STAMP_SELECTION, MemoryCartRepo
|
||||
from cart_repo import STAMP_CLAIMING, STAMP_DONE, STAMP_SELECTION, MemoryCartRepo
|
||||
|
||||
|
||||
HANGZHOU = "杭州小药药自营旗舰店"
|
||||
|
|
@ -9,6 +14,7 @@ YBM = 48
|
|||
YAOEX = 66
|
||||
|
||||
|
||||
# 构造一条待加购表 2 行。
|
||||
def _row(**overrides) -> dict:
|
||||
data = {
|
||||
"fid": 1,
|
||||
|
|
@ -27,6 +33,7 @@ def _row(**overrides) -> dict:
|
|||
return data
|
||||
|
||||
|
||||
# 构造一条能对上仓的加购候选。
|
||||
def _offer(vendor: str, **overrides) -> CartOffer:
|
||||
data = dict(
|
||||
sku_id="S-" + vendor[:2],
|
||||
|
|
@ -39,6 +46,7 @@ def _offer(vendor: str, **overrides) -> CartOffer:
|
|||
return CartOffer(**data)
|
||||
|
||||
|
||||
# 杭州 3 + 广东 7 两行都加车,表 2 仍两行。
|
||||
def test_job_adds_split_rows_without_insert():
|
||||
repo = MemoryCartRepo(
|
||||
[
|
||||
|
|
@ -48,18 +56,22 @@ def test_job_adds_split_rows_without_insert():
|
|||
)
|
||||
added: list[tuple[int, str, int]] = []
|
||||
|
||||
# 杭州仓有货。
|
||||
def search_ybm(bar_code: str):
|
||||
assert bar_code == "06901234567890"
|
||||
return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)]
|
||||
|
||||
# 广东仓有货。
|
||||
def search_yaoex(bar_code: str):
|
||||
assert bar_code == "06901234567890"
|
||||
return [_offer(GUANGDONG, sku_id="GD", stock=20.0)]
|
||||
|
||||
# 记下药帮忙加车。
|
||||
def add_ybm(offer: CartOffer, qty: int):
|
||||
added.append((YBM, offer.sku_id, qty))
|
||||
return AddOutcome(kind=KIND_OK)
|
||||
|
||||
# 记下 1 号药城加车。
|
||||
def add_yaoex(offer: CartOffer, qty: int):
|
||||
added.append((YAOEX, offer.sku_id, qty))
|
||||
return AddOutcome(kind=KIND_OK)
|
||||
|
|
@ -82,9 +94,45 @@ def test_job_adds_split_rows_without_insert():
|
|||
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():
|
||||
repo = MemoryCartRepo([_row(purchase_qty=10)])
|
||||
|
||||
# 库存只有 4。
|
||||
def search_yaoex(bar_code: str):
|
||||
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]["fail_reason"] == "库存不足"
|
||||
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
||||
assert repo.shift_errors == []
|
||||
|
||||
|
||||
def test_job_marks_login_fail_as_system_error():
|
||||
"""登录失败把戳改回系统管理员,status 保持 1,下一班还能领。"""
|
||||
repo = MemoryCartRepo(
|
||||
[
|
||||
_row(fid=1, platform_type=YAOEX),
|
||||
|
|
@ -110,9 +160,11 @@ def test_job_marks_login_fail_as_system_error():
|
|||
)
|
||||
ybm_added = []
|
||||
|
||||
# 杭州仓有货。
|
||||
def search_ybm(bar_code: str):
|
||||
return [_offer(HANGZHOU)]
|
||||
|
||||
# 记下药帮忙加车数量。
|
||||
def add_ybm(offer: CartOffer, qty: int):
|
||||
ybm_added.append(qty)
|
||||
return AddOutcome(kind=KIND_OK)
|
||||
|
|
@ -125,18 +177,28 @@ def test_job_marks_login_fail_as_system_error():
|
|||
)
|
||||
assert summary.added == 1
|
||||
assert summary.failed == 1
|
||||
assert repo.rows[1]["status"] == 2
|
||||
assert repo.rows[1]["fail_reason"].startswith("系统故障:")
|
||||
assert "账号密码错误" in repo.rows[1]["fail_reason"]
|
||||
assert repo.rows[1]["status"] == 1
|
||||
assert repo.rows[1]["update_user_name"] == STAMP_SELECTION
|
||||
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]["update_user_name"] == STAMP_DONE
|
||||
assert ybm_added == [3]
|
||||
again = repo.claim_pending()
|
||||
assert [row.fid for row in again] == [1]
|
||||
|
||||
|
||||
# 已盖加购班的行本班不领。
|
||||
def test_job_skips_already_stamped_rows():
|
||||
repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)])
|
||||
called = []
|
||||
|
||||
# 不应被调用。
|
||||
def search_yaoex(bar_code: str):
|
||||
called.append(bar_code)
|
||||
return [_offer(GUANGDONG)]
|
||||
|
|
@ -152,9 +214,11 @@ def test_job_skips_already_stamped_rows():
|
|||
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
||||
|
||||
|
||||
# 对不上 supplier_name 时写未找到对应仓。
|
||||
def test_job_marks_missing_warehouse():
|
||||
repo = MemoryCartRepo([_row()])
|
||||
|
||||
# 搜到的是杭州仓,对不上广东。
|
||||
def search_yaoex(bar_code: str):
|
||||
return [_offer(HANGZHOU)]
|
||||
|
||||
|
|
@ -169,29 +233,15 @@ def test_job_marks_missing_warehouse():
|
|||
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():
|
||||
repo = MemoryCartRepo([_row()])
|
||||
|
||||
# 广东仓有货。
|
||||
def search_yaoex(bar_code: str):
|
||||
return [_offer(GUANGDONG)]
|
||||
|
||||
# 平台返回中包装加不进。
|
||||
def add_yaoex(offer: CartOffer, qty: int):
|
||||
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的倍数"
|
||||
|
||||
|
||||
# 只跑一个平台时另一平台行仍停在系统管理员。
|
||||
def test_job_platform_filter_leaves_other_mall_pending():
|
||||
repo = MemoryCartRepo(
|
||||
[
|
||||
|
|
@ -213,6 +264,7 @@ def test_job_platform_filter_leaves_other_mall_pending():
|
|||
]
|
||||
)
|
||||
|
||||
# 广东仓有货。
|
||||
def search_yaoex(bar_code: str):
|
||||
return [_offer(GUANGDONG)]
|
||||
|
||||
|
|
@ -231,10 +283,12 @@ def test_job_platform_filter_leaves_other_mall_pending():
|
|||
|
||||
|
||||
def test_job_search_error_is_system_fail():
|
||||
"""查询失败原因列只写 QUERY_FAILED,戳改回系统管理员,status 保持 1。"""
|
||||
repo = MemoryCartRepo([_row()])
|
||||
|
||||
# 抛带原文的查询错。
|
||||
def search_yaoex(bar_code: str):
|
||||
raise RuntimeError("网关 502")
|
||||
raise RuntimeError("网关 502 secret-traceback")
|
||||
|
||||
summary = run_cart_batch(
|
||||
repo,
|
||||
|
|
@ -243,5 +297,155 @@ def test_job_search_error_is_system_fail():
|
|||
login_errors={},
|
||||
)
|
||||
assert summary.failed == 1
|
||||
assert repo.rows[1]["fail_reason"].startswith("系统故障:")
|
||||
assert "网关 502" in repo.rows[1]["fail_reason"]
|
||||
reason = 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:
|
||||
|
|
@ -46,11 +46,3 @@ def test_same_supplier_keeps_cheaper():
|
|||
|
||||
def test_missing_supplier_returns_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 (
|
||||
LEASE_MINUTES,
|
||||
STAMP_CLAIMING,
|
||||
STAMP_DONE,
|
||||
STAMP_SELECTION,
|
||||
MemoryCartRepo,
|
||||
OracleCartRepo,
|
||||
_pending_select_sql,
|
||||
)
|
||||
|
||||
|
||||
# 构造一条待加购表 2 行。
|
||||
def _row(**overrides) -> dict:
|
||||
data = {
|
||||
"fid": 1,
|
||||
|
|
@ -25,6 +41,7 @@ def _row(**overrides) -> dict:
|
|||
return data
|
||||
|
||||
|
||||
# 系统管理员成功行领走盖成加购中,已完成和失败行不领。
|
||||
def test_claim_pending_stamps_processing_and_skips_done():
|
||||
repo = MemoryCartRepo(
|
||||
[
|
||||
|
|
@ -42,6 +59,29 @@ def test_claim_pending_stamps_processing_and_skips_done():
|
|||
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():
|
||||
repo = MemoryCartRepo([_row()])
|
||||
repo.claim_pending()
|
||||
|
|
@ -53,6 +93,7 @@ def test_mark_success_does_not_insert_or_change_status():
|
|||
assert repo.rows[1]["push_status"] == 0
|
||||
|
||||
|
||||
# 失败改 status=2 并写下原因。
|
||||
def test_mark_fail_sets_status_and_reason():
|
||||
repo = MemoryCartRepo([_row()])
|
||||
repo.claim_pending()
|
||||
|
|
@ -63,26 +104,103 @@ def test_mark_fail_sets_status_and_reason():
|
|||
assert repo.rows[1]["update_user_name"] == STAMP_DONE
|
||||
|
||||
|
||||
def test_oracle_claim_updates_stamp():
|
||||
fetched = [
|
||||
(
|
||||
11,
|
||||
2026100201,
|
||||
66,
|
||||
"广东壹号药业有限公司",
|
||||
"0690",
|
||||
7,
|
||||
1,
|
||||
None,
|
||||
0,
|
||||
STAMP_SELECTION,
|
||||
1,
|
||||
)
|
||||
]
|
||||
executed: list[tuple[str, dict]] = []
|
||||
# 加购 .env 空缺时补选品和 oms .env,已有非空环境变量不覆盖。
|
||||
def test_load_runtime_env_fills_blank_from_other_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")
|
||||
cart_env = tmp_path / "cart.env"
|
||||
select_env = tmp_path / "select.env"
|
||||
oms_env = tmp_path / "oms.env"
|
||||
cart_env.write_text("RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\n", encoding="utf-8")
|
||||
select_env.write_text("RPA_ORACLE_PASSWORD=from-select\nRPA_ORACLE_USER=from-select\n", encoding="utf-8")
|
||||
oms_env.write_text("RPA_ORACLE_PASSWORD=from-oms\n", encoding="utf-8")
|
||||
load_job_env(tmp_path, [cart_env, select_env, oms_env])
|
||||
assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
|
||||
assert os.environ["RPA_ORACLE_PASSWORD"] == "from-select"
|
||||
assert os.environ["RPA_ORACLE_USER"] == "keep-me"
|
||||
|
||||
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",),
|
||||
("OMS_ID",),
|
||||
("PLATFORM_TYPE",),
|
||||
|
|
@ -94,43 +212,126 @@ def test_oracle_claim_updates_stamp():
|
|||
("PUSH_STATUS",),
|
||||
("UPDATE_USER_NAME",),
|
||||
("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):
|
||||
return fetched
|
||||
# 领取 SQL 带 FOR UPDATE SKIP LOCKED,过期加购中可再领。
|
||||
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):
|
||||
return FakeCursor()
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def rollback(self):
|
||||
raise AssertionError("不应 rollback")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
conn = FakeConn()
|
||||
repo = OracleCartRepo(connect=lambda: conn)
|
||||
# 领取一次借连接:SKIP LOCKED 选出后盖成加购中。
|
||||
def test_oracle_claim_updates_stamp():
|
||||
conn = _FakeConn(rows=[_fetched_row()], description=_CLAIM_DESCRIPTION)
|
||||
pool = _FakePool(conn)
|
||||
repo = OracleCartRepo(pool)
|
||||
claimed = repo.claim_pending()
|
||||
assert pool.acquires == 1
|
||||
assert conn.committed is True
|
||||
assert len(claimed) == 1
|
||||
assert claimed[0].fid == 11
|
||||
assert claimed[0].update_user_name == STAMP_CLAIMING
|
||||
assert "update_user_name" in executed[1][0].lower()
|
||||
assert executed[1][1]["fid"] == 11
|
||||
assert executed[1][1]["stamp"] == STAMP_CLAIMING
|
||||
select_sql, select_binds = conn.executed[0]
|
||||
assert "skip locked" in select_sql.lower()
|
||||
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():
|
||||
args = parse_args([])
|
||||
assert args.days == 0
|
||||
assert args.dry_run is False
|
||||
assert args.xls is None
|
||||
assert args.command == "pay"
|
||||
|
||||
|
||||
def test_parse_args_xls_dry_run():
|
||||
args = parse_args(["--xls", "/tmp/orders.xls", "--dry-run"])
|
||||
assert args.dry_run is True
|
||||
assert str(args.xls).endswith("orders.xls")
|
||||
assert args.command == "pay"
|
||||
def test_parse_args_pay_rejects_mode_switches():
|
||||
with pytest.raises(SystemExit):
|
||||
parse_args(["--xls", "/tmp/orders.xls", "--dry-run"])
|
||||
|
||||
|
||||
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():
|
||||
"""显式 --limit 仍覆盖默认 20。"""
|
||||
args = parse_args(["cart", "--limit", "5", "--platform", "yaoex"])
|
||||
assert args.command == "cart"
|
||||
assert args.limit == 5
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
# 进入 with,返回自身。
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
# 退出 with,不吞异常。
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
executed.append((sql, params or {}))
|
||||
|
||||
# 记下一次批量 SQL 和全部 bind。
|
||||
def executemany(self, sql, params):
|
||||
for item in params:
|
||||
executed.append((sql, item))
|
||||
many.append((sql, [dict(item) for item in params]))
|
||||
|
||||
# 提交后记下 committed。
|
||||
class FakeConn:
|
||||
committed = False
|
||||
|
||||
# 新开录 SQL 的游标。
|
||||
def cursor(self):
|
||||
return FakeCursor()
|
||||
|
||||
# 记下已提交。
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
# 本套用例不应回滚。
|
||||
def rollback(self):
|
||||
raise AssertionError("不应 rollback")
|
||||
|
||||
# 假连接不计数。
|
||||
def close(self):
|
||||
pass
|
||||
return None
|
||||
|
||||
conn = FakeConn()
|
||||
repo = OraclePayRepo(connect=lambda: conn)
|
||||
repo.save_pay_rows(
|
||||
[
|
||||
{
|
||||
repo = OraclePayRepo(_FakePool(conn))
|
||||
row = {
|
||||
"operate_type": 2,
|
||||
"oms_id": 0,
|
||||
"platform_type": 66,
|
||||
|
|
@ -54,38 +76,55 @@ def test_save_pay_rows_inserts_operate_type_2():
|
|||
"org_code": None,
|
||||
"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 "rpa_cart_settlement_goods_data" in executed[0][0].lower()
|
||||
assert executed[0][1]["operate_type"] == 2
|
||||
assert executed[0][1]["oms_id"] == 0
|
||||
assert executed[0][1]["pay_order_no"] == "ZQD1"
|
||||
assert executed[0][1]["pay_order_dtl_id"] == "a1b2c3d4e5f6789012345678901234ab"
|
||||
assert len(many) == 1
|
||||
sql, binds = many[0]
|
||||
assert "rpa_cart_settlement_goods_data" in sql.lower()
|
||||
assert [item["operate_type"] for item in binds] == [2, 2]
|
||||
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():
|
||||
# 返回预设支付单号。
|
||||
class FakeCursor:
|
||||
# 进入 with,返回自身。
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
# 退出 with,不吞异常。
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
# 记下 SQL 和 bind。
|
||||
def execute(self, sql, params=None):
|
||||
self.sql = sql
|
||||
self.params = params
|
||||
|
||||
# 返回含重复和空值的单号。
|
||||
def fetchall(self):
|
||||
return [("ZQD1",), ("ZQD1",), (None,)]
|
||||
|
||||
# 只提供游标。
|
||||
class FakeConn:
|
||||
# 新开返回预设单号的游标。
|
||||
def cursor(self):
|
||||
return FakeCursor()
|
||||
|
||||
# 假连接不计数。
|
||||
def close(self):
|
||||
pass
|
||||
return None
|
||||
|
||||
repo = OraclePayRepo(connect=lambda: FakeConn())
|
||||
repo = OraclePayRepo(_FakePool(FakeConn()))
|
||||
keys = repo.load_existing_pay_order_nos()
|
||||
assert keys == {"ZQD1"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""1 号药城订单导出。"""
|
||||
# 1 号药城订单导出。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
|
@ -15,6 +16,7 @@ GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
|
|||
EXPORT_URL = f"{GATEWAY}/api/order/export"
|
||||
|
||||
|
||||
# 导出用的起止时间。默认只含当天,从 0 点到 23:59:59。
|
||||
def day_range(day: date | None = None, days: int = 0) -> tuple[str, str]:
|
||||
end = day or date.today()
|
||||
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(
|
||||
token: str,
|
||||
*,
|
||||
|
|
@ -62,6 +65,7 @@ def export_orders(
|
|||
client.close()
|
||||
|
||||
|
||||
# 拼 1 号药城导出表单,带上登录 token。
|
||||
def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = {
|
||||
"traderName": "yaoex_pc",
|
||||
|
|
@ -77,6 +81,7 @@ def _form(token: str, extra: dict[str, Any]) -> dict[str, Any]:
|
|||
return payload
|
||||
|
||||
|
||||
# 1 号药城订单导出请求头。来源页是订单列表。
|
||||
def _headers() -> dict[str, str]:
|
||||
return {
|
||||
"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
|
||||
|
||||
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))
|
||||
|
||||
from auth_login import ( # noqa: E402
|
||||
import selection_path # noqa: F401 先把 rpa_selection 放进导入路径
|
||||
from rpa_oracle import load_job_env
|
||||
from auth_login import (
|
||||
DEFAULT_YBM_COOKIE_PATH,
|
||||
DEFAULT_YAOEX_AUTH_PATH,
|
||||
BrowserRuntime,
|
||||
load_cookie_file,
|
||||
load_dotenv,
|
||||
load_secrets,
|
||||
load_yaoex_auth_file,
|
||||
login_yaoex,
|
||||
|
|
@ -21,17 +20,17 @@ from auth_login import ( # noqa: E402
|
|||
save_cookie_file,
|
||||
save_yaoex_auth,
|
||||
)
|
||||
from search_yaoex import YaoexAuth # noqa: E402
|
||||
from search_yaoex import YaoexAuth
|
||||
|
||||
|
||||
# 读加购 / 选品 / oms 的 .env,不覆盖已有非空变量。
|
||||
def load_cart_dotenv() -> None:
|
||||
load_dotenv(Path(__file__).resolve().parent / ".env")
|
||||
load_dotenv(SELECTION_ROOT / ".env")
|
||||
load_job_env(Path(__file__).resolve().parent)
|
||||
|
||||
|
||||
# 拿到 1 号药城登录态。有缓存且 token 有效则直接用,否则走浏览器登录。
|
||||
def resolve_yaoex_auth(
|
||||
*,
|
||||
secrets_path: Path | None = None,
|
||||
auth_path: Path | None = None,
|
||||
fresh: bool = False,
|
||||
runtime: BrowserRuntime | None = None,
|
||||
|
|
@ -41,13 +40,15 @@ def resolve_yaoex_auth(
|
|||
auth = load_yaoex_auth_file(cached)
|
||||
if auth.token and auth.token != "t":
|
||||
return auth
|
||||
secrets = load_secrets(secrets_path)
|
||||
secrets = load_secrets()
|
||||
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
|
||||
session = runtime or BrowserRuntime(keep=False)
|
||||
try:
|
||||
auth = login_yaoex(secrets_path, runtime=session)
|
||||
auth = login_yaoex(runtime=session)
|
||||
save_yaoex_auth(auth, cached)
|
||||
return auth
|
||||
finally:
|
||||
|
|
@ -55,9 +56,9 @@ def resolve_yaoex_auth(
|
|||
session.close()
|
||||
|
||||
|
||||
# 拿到药帮忙登录 cookie。有缓存则直接用,否则走浏览器登录。
|
||||
def resolve_ybm_cookies(
|
||||
*,
|
||||
secrets_path: Path | None = None,
|
||||
cookie_path: Path | None = None,
|
||||
fresh: bool = False,
|
||||
runtime: BrowserRuntime | None = None,
|
||||
|
|
@ -67,13 +68,15 @@ def resolve_ybm_cookies(
|
|||
cookies = load_cookie_file(cached)
|
||||
if cookies:
|
||||
return cookies
|
||||
secrets = load_secrets(secrets_path)
|
||||
secrets = load_secrets()
|
||||
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
|
||||
session = runtime or BrowserRuntime(keep=False)
|
||||
try:
|
||||
cookies = login_ybm(secrets_path, runtime=session)
|
||||
cookies = login_ybm(runtime=session)
|
||||
save_cookie_file(cookies, cached)
|
||||
return cookies
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -12,26 +12,28 @@
|
|||
|
||||
口径:`docs/RPA订单中心接口开发口径_20260930.md` 第 4 节;合单细节见 `docs/合同系统录入流程.md`。
|
||||
|
||||
## 测试账号
|
||||
## 测试环境
|
||||
|
||||
地址:`http://contract-test.gzmpc.com:8080/`
|
||||
账号:`10007228 / 1`
|
||||
账密只认环境变量 `CONTRACT_USERNAME` / `CONTRACT_PASSWORD`,缺则入口失败。不要把密码写进仓库。
|
||||
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
|
||||
|
||||
## 页面步骤
|
||||
|
||||
脚本直达起草/模板 URL:
|
||||
脚本直达起草/模板 URL。修一步只打开对应文件;`contract_flow_steps.py` 是对外入口。
|
||||
|
||||
1. 打开合同系统并登录
|
||||
2. 进起草,套非格式采购协议模板
|
||||
3. 签约方:客户编码、客户名称
|
||||
1. 打开合同系统并登录(`contract_login.py` S02–S05)
|
||||
2. 进起草,套非格式采购协议模板(直达 `FORM_PAGE_URL`)
|
||||
3. 签约方:客户编码、客户名称(`contract_form.py` S06)
|
||||
4. 尽调:页面正文含「未尽调」则本单失败,不提交
|
||||
5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同
|
||||
6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`)
|
||||
7. 货品明细
|
||||
8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」
|
||||
9. 点提交,不要点暂存
|
||||
10. 从「我的合同」读正式合同号
|
||||
5. 基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质=主合同(S07)
|
||||
6. 金额与日期:签订日 / 开始日 / 结束日用接口 3 第一行三日期(JSON 干跑未给则回退 `orderDate`)(S08)
|
||||
7. 货品明细:弹窗按操作码搜,找不到则本单失败(`contract_goods.py` S09)
|
||||
8. 付款与标志:这期一律账期,不勾预付款;页面「账期支付」对应选项「电汇」(S10)
|
||||
9. 挂合同正文(`contract_upload.py` S11);点提交(`contract_submit.py` S12),不要只点暂存
|
||||
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`。
|
||||
|
||||
## 运维
|
||||
|
||||
### 怎么跑
|
||||
|
||||
```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
|
||||
|
||||
import os
|
||||
|
|
@ -17,10 +15,14 @@ PACKAGE_ROOT = Path(__file__).resolve().parent
|
|||
SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots"
|
||||
|
||||
|
||||
# 能按名字截图的浏览器。
|
||||
class HasScreenshot(Protocol):
|
||||
def screenshot(self, name: str) -> str: ...
|
||||
# 按名字截一张图,返回保存路径。
|
||||
def screenshot(self, name: str) -> str:
|
||||
...
|
||||
|
||||
|
||||
# 验收的一步:步骤号、标题、是否通过、读到的事实、对不上的字段。
|
||||
@dataclass
|
||||
class AcceptLine:
|
||||
step: str
|
||||
|
|
@ -30,23 +32,26 @@ class AcceptLine:
|
|||
mismatches: list[str]
|
||||
|
||||
|
||||
# 把流程名收成目录名。空名用 default,其余只留字母、数字、横线和底线。
|
||||
def safe_flow_name(flow: str) -> str:
|
||||
text = (flow or "default").strip() or "default"
|
||||
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text)
|
||||
|
||||
|
||||
# 这个流程的截图和 ACCEPT.txt 放在哪。
|
||||
def artifact_dir(flow: str) -> Path:
|
||||
return SCREENSHOT_ROOT / safe_flow_name(flow)
|
||||
|
||||
|
||||
# 默认只在失败时截图。RPA_SCREENSHOT=always 时每步都留。
|
||||
def evidence_shot(browser: HasScreenshot, name: str, *, failed: bool = False) -> str:
|
||||
"""Save a screenshot only on failure. Set RPA_SCREENSHOT=always to keep every frame."""
|
||||
mode = (os.environ.get("RPA_SCREENSHOT") or "fail").strip().lower()
|
||||
if not failed and mode != "always":
|
||||
return ""
|
||||
return browser.screenshot(name)
|
||||
|
||||
|
||||
# ACCEPT.txt 的路径。RPA_ACCEPT_LOG 可改到别处,单测用这个钉住文件。
|
||||
def accept_log_path(flow: str = "default") -> Path:
|
||||
override = os.environ.get("RPA_ACCEPT_LOG")
|
||||
if override:
|
||||
|
|
@ -54,6 +59,7 @@ def accept_log_path(flow: str = "default") -> Path:
|
|||
return artifact_dir(flow) / "ACCEPT.txt"
|
||||
|
||||
|
||||
# 页面读数和期望是否同一值。空期望算通过;数字、是否、去空白后包含也算对上。
|
||||
def field_match(actual: str, expected: str) -> bool:
|
||||
actual_text = (actual 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
|
||||
|
||||
|
||||
# 把一步收成控制台一行:ACCEPT、步骤、ok 或 FAIL、标题和读到的字段。
|
||||
def format_accept(line: AcceptLine) -> str:
|
||||
status = "ok" if line.ok else "FAIL"
|
||||
facts = " ".join(f"{key}={value}" for key, value in line.facts.items())
|
||||
|
|
@ -85,16 +92,17 @@ def format_accept(line: AcceptLine) -> str:
|
|||
return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip()
|
||||
|
||||
|
||||
# 一次页面流的验收单。每步打印并覆写 ACCEPT.txt,对不上就抛 NodeError。
|
||||
@dataclass
|
||||
class AcceptanceReport:
|
||||
"""DOM / page-state checklist. One instance per flow run."""
|
||||
|
||||
flow: str = "default"
|
||||
lines: list[AcceptLine] = field(default_factory=list)
|
||||
|
||||
# 清空已记的步骤,下一次页面流重新计。
|
||||
def reset(self) -> None:
|
||||
self.lines = []
|
||||
|
||||
# 核对一组字段。对不上就记 FAIL、失败时截图,并抛 NodeError 停下这一步。
|
||||
def check(
|
||||
self,
|
||||
step: str,
|
||||
|
|
@ -122,6 +130,7 @@ class AcceptanceReport:
|
|||
evidence_shot(browser, screenshot_on_fail, failed=True)
|
||||
raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches))
|
||||
|
||||
# 记一步事实,不和期望逐字段比。ok 为假且 raise_error 时抛 NodeError。
|
||||
def note(
|
||||
self,
|
||||
step: str,
|
||||
|
|
@ -138,6 +147,7 @@ class AcceptanceReport:
|
|||
if not ok and raise_error:
|
||||
raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items()))
|
||||
|
||||
# 把当前汇总写进 ACCEPT.txt。写失败只打印,不把页面流打成另一次错误。
|
||||
def dump(self) -> None:
|
||||
path = accept_log_path(self.flow)
|
||||
try:
|
||||
|
|
@ -146,6 +156,7 @@ class AcceptanceReport:
|
|||
except OSError as exc:
|
||||
print(f"ACCEPT log write skipped: {exc}", flush=True)
|
||||
|
||||
# 汇总文本:第一行是通过数,后面是每步的 ACCEPT 行。
|
||||
def summary_text(self) -> str:
|
||||
passed = sum(1 for line in self.lines if line.ok)
|
||||
rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"]
|
||||
|
|
|
|||
|
|
@ -1,627 +1,33 @@
|
|||
# coding=utf-8
|
||||
"""
|
||||
Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。
|
||||
"""
|
||||
# 合同班浏览器适配。复用 rpa_browser 共享适配器。
|
||||
#
|
||||
# 本文件只做导入入口,步骤函数仍 from apa_browser_adapter import ApaBrowserAdapter。
|
||||
# Chrome 用户目录和调试口用共享默认:CONTRACT_CREATE 口 9333。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
_FLOW_DEBUG_PORTS = {
|
||||
"CONTRACT_CREATE": 9333,
|
||||
"REBATE_FIND": 9335,
|
||||
"default": 9334,
|
||||
}
|
||||
|
||||
|
||||
def _is_inside_repo(path: Path, repo_root: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(repo_root.resolve())
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path:
|
||||
"""Chrome user-data dir must stay outside the repo so list_dir never walks 3GB."""
|
||||
root = (repo_root or REPO_ROOT).resolve()
|
||||
outside = (root.parent / "rpa-chrome-profile").resolve()
|
||||
raw = os.getenv("RPA_CHROME_PROFILE") if requested is None else requested
|
||||
if raw:
|
||||
candidate = Path(raw).expanduser().resolve()
|
||||
if _is_inside_repo(candidate, root):
|
||||
print(f"RPA_CHROME_PROFILE 指向仓库内 {candidate},已改用 {outside}", flush=True)
|
||||
return outside
|
||||
return candidate
|
||||
return outside
|
||||
|
||||
|
||||
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)
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from rpa_browser.adapter import ( # noqa: E402
|
||||
ApaBrowserAdapter,
|
||||
REPO_ROOT,
|
||||
chrome_debug_port,
|
||||
resolve_chrome_profile_base,
|
||||
resolve_chrome_profile_dir,
|
||||
wait_debug_port,
|
||||
wait_devtools_port,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ApaBrowserAdapter",
|
||||
"REPO_ROOT",
|
||||
"chrome_debug_port",
|
||||
"resolve_chrome_profile_base",
|
||||
"resolve_chrome_profile_dir",
|
||||
"wait_debug_port",
|
||||
"wait_devtools_port",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import argparse
|
||||
|
|
@ -12,7 +16,6 @@ from apa_browser_adapter import ApaBrowserAdapter
|
|||
from contract_draft import (
|
||||
ContractDraft,
|
||||
contract_draft_from_dict,
|
||||
is_placeholder_customer_code,
|
||||
load_contract_draft,
|
||||
)
|
||||
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/"
|
||||
|
||||
|
||||
# 入参缺失 / 页面元素找不到 / 页面改版。
|
||||
class ContractCreateError(NodeError):
|
||||
"""入参缺失 / 页面元素找不到 / 页面改版。"""
|
||||
pass
|
||||
|
||||
|
||||
# 返回本包 examples 下默认干跑 JSON 的路径。
|
||||
def default_contract_input_path() -> str:
|
||||
return str(
|
||||
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
|
||||
)
|
||||
|
||||
|
||||
# 一次合同起草的入参。账密为空时由 resolve_contract_credentials 补环境变量。
|
||||
@dataclass(frozen=True)
|
||||
class ContractCreateInput:
|
||||
job_id: str
|
||||
|
|
@ -41,8 +47,8 @@ class ContractCreateInput:
|
|||
contract_system_no: str | None = None
|
||||
attempt_no: int = 1
|
||||
contract_url: str = CONTRACT_URL
|
||||
username: str = "10007228"
|
||||
password: str = "1"
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
submit_mode: str = "submit"
|
||||
order_no_list: tuple[str, ...] = ()
|
||||
template_name: str = ""
|
||||
|
|
@ -54,6 +60,24 @@ class ContractCreateInput:
|
|||
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(
|
||||
spec: ContractCreateInput,
|
||||
*,
|
||||
|
|
@ -80,8 +104,8 @@ def execute_contract_create(
|
|||
}
|
||||
|
||||
|
||||
# 在合同系统中起草合同并返回合同编号。
|
||||
def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
|
||||
"""在合同系统中起草合同并返回合同编号。"""
|
||||
if 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)
|
||||
|
||||
|
||||
# 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。
|
||||
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
|
||||
draft = contract_draft_from_dict(data)
|
||||
_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")),
|
||||
attempt_no=int(data.get("attemptNo", 1) or 1),
|
||||
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
|
||||
username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "10007228"),
|
||||
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")),
|
||||
username=_clean(data.get("username")),
|
||||
password="" if data.get("password") in (None, "") else str(data.get("password")),
|
||||
submit_mode=draft.submit_mode,
|
||||
order_no_list=tuple(draft.order_no_list),
|
||||
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)
|
||||
|
||||
|
||||
# 把草稿和入参收成页面上下文。账密缺环境变量时在这里失败。
|
||||
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
|
||||
merged_pdf_path = _resolve_merged_pdf(draft, spec)
|
||||
username, password = resolve_contract_credentials(spec.username, spec.password)
|
||||
return ContractFlowContext(
|
||||
contract_url=spec.contract_url,
|
||||
username=spec.username,
|
||||
password=spec.password,
|
||||
username=username,
|
||||
password=password,
|
||||
supplier_name=draft.supplier_name or spec.supplier_name,
|
||||
order_date=draft.order_date or spec.order_date,
|
||||
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:
|
||||
if draft.source_type != "YYC":
|
||||
return None
|
||||
|
|
@ -185,6 +213,7 @@ def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str
|
|||
raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc
|
||||
|
||||
|
||||
# 组装提交成功后的摘要字段。
|
||||
def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
|
||||
return {
|
||||
"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:
|
||||
missing = []
|
||||
for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
|
||||
if not _str(data.get(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"):
|
||||
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))
|
||||
|
||||
|
||||
# 取出必填字符串。缺或空白则失败。
|
||||
def _require(data: dict[str, Any], key: str) -> str:
|
||||
value = _str(data.get(key))
|
||||
if not value:
|
||||
|
|
@ -240,17 +264,20 @@ def _require(data: dict[str, Any], key: str) -> str:
|
|||
return value
|
||||
|
||||
|
||||
# 把值收成非空字符串,空白则 None。
|
||||
def _opt(value: Any) -> str | None:
|
||||
value = _str(value)
|
||||
return value or None
|
||||
|
||||
|
||||
# 把任意值收成去两端空白的字符串。None 当空。
|
||||
def _str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
# 去掉两端空白和成对反引号。
|
||||
def _clean(value: Any) -> str:
|
||||
value = _str(value)
|
||||
if len(value) >= 2 and value[0] == "`" and value[-1] == "`":
|
||||
|
|
@ -258,8 +285,8 @@ def _clean(value: Any) -> str:
|
|||
return value
|
||||
|
||||
|
||||
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。
|
||||
def load_input_data(raw: str) -> dict[str, Any]:
|
||||
"""解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。"""
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
fallback = default_contract_input_path()
|
||||
|
|
@ -280,11 +307,13 @@ def load_input_data(raw: str) -> dict[str, Any]:
|
|||
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
|
||||
|
||||
|
||||
# 从 JSON 字典起草合同并返回结果。
|
||||
def run_contract_create(data: dict[str, Any]) -> dict[str, Any]:
|
||||
spec = build_spec_from_data(data)
|
||||
return execute_contract_create(spec)
|
||||
|
||||
|
||||
# 解析命令行。账密默认读环境变量,没有内置账号。
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="合同系统页面起草。")
|
||||
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("--attempt-no", type=int, default=1)
|
||||
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("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
||||
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME"))
|
||||
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD"))
|
||||
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
||||
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
# 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
|
|
@ -310,6 +340,7 @@ def main() -> int:
|
|||
else:
|
||||
if not (args.job_id and args.supplier_name and args.order_date):
|
||||
raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json")
|
||||
username, password = resolve_contract_credentials(args.username, args.password)
|
||||
spec = ContractCreateInput(
|
||||
job_id=args.job_id,
|
||||
supplier_name=args.supplier_name,
|
||||
|
|
@ -318,8 +349,8 @@ def main() -> int:
|
|||
contract_system_no=args.contract_system_no,
|
||||
attempt_no=args.attempt_no,
|
||||
contract_url=args.contract_url,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
username=username,
|
||||
password=password,
|
||||
draft_path=args.draft_path,
|
||||
merged_pdf_output=args.merged_pdf_output,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Contract draft input model for CONTRACT_CREATE."""
|
||||
# 合同起草入参。页面流按这份草稿填客商、金额、日期和货品行。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
|
@ -8,15 +9,8 @@ from typing import Any
|
|||
|
||||
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)
|
||||
class ContractDraft:
|
||||
supplier_name: str
|
||||
|
|
@ -50,13 +44,16 @@ class ContractDraft:
|
|||
merged_pdf_path: str | None = None
|
||||
contract_document_path: str = ""
|
||||
order_no_list: list[str] = field(default_factory=list)
|
||||
lookup_only: bool = False
|
||||
|
||||
|
||||
# 从 JSON 文件读起草草稿。
|
||||
def load_contract_draft(path: str | Path) -> ContractDraft:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return contract_draft_from_dict(data)
|
||||
|
||||
|
||||
# 把 JSON 字典收成起草草稿。缺的字段用页面上的默认值。
|
||||
def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
|
||||
goods_lines = [
|
||||
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):
|
||||
error_code = "UI_ELEMENT_NOT_FOUND"
|
||||
retryable = False
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
# 合同系统登录失败。可以重试。
|
||||
class LoginFailed(NodeError):
|
||||
error_code = "SYS_LOGIN_FAIL"
|
||||
retryable = True
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
# 供应商在合同系统里不能起草。不重试,交给采购。
|
||||
class SupplierContractDisabled(NodeError):
|
||||
error_code = "BIZ_CONTRACT_DISABLED"
|
||||
retryable = False
|
||||
route_role = "PURCHASER"
|
||||
|
||||
|
||||
# YYC 多份平台 PDF 合并不了。不重试,交给运维。
|
||||
class PdfMergeUnsupported(NodeError):
|
||||
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
|
||||
retryable = False
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
# 页面流里没归类的异常。可以重试。
|
||||
class UnknownRpaError(NodeError):
|
||||
error_code = "RPA_UNKNOWN_ERROR"
|
||||
retryable = True
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
# 客商未尽调。不提交合同,不重试,交给采购。
|
||||
class DiligenceError(NodeError):
|
||||
error_code = "BIZ_CUSTOMER_NOT_DILIGENCE"
|
||||
retryable = False
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""合同班合单:可录判定、按供应商分组、草稿映射。"""
|
||||
# 合同班合单:可录判定、按供应商+货主+三个日期分组、草稿映射。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
|
|
@ -7,25 +8,48 @@ from decimal import Decimal, ROUND_HALF_UP
|
|||
|
||||
from contract_draft import ContractDraft
|
||||
from contract_flow_steps import ContractGoodsLine
|
||||
from persist import LOOKUP_ONLY_RESULT
|
||||
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:
|
||||
if int(row.process_status or 0) != 0:
|
||||
return False
|
||||
if row.contract_entry_status is not None and int(row.contract_entry_status) == 0:
|
||||
if (row.contract_no or "").strip():
|
||||
return False
|
||||
return True
|
||||
return not is_protocol_rerun(row)
|
||||
|
||||
|
||||
# 合单键:供应商、货主、签订日、开始日、结束日。任一不同则各自成组。
|
||||
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]]:
|
||||
groups: OrderedDict[str, list[PurchaseContractRow]] = OrderedDict()
|
||||
groups: OrderedDict[tuple, list[PurchaseContractRow]] = OrderedDict()
|
||||
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())
|
||||
|
||||
|
||||
# 检查合单是否缺供应商或货品 MDM 码。缺则返回原因,齐则返回 None。
|
||||
def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
|
||||
if not rows:
|
||||
return "缺少供应商MDM码"
|
||||
|
|
@ -37,11 +61,13 @@ def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
# 把金额收成四位小数字符串,四舍五入。
|
||||
def money4(value) -> str:
|
||||
amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||||
return f"{amount:.4f}"
|
||||
|
||||
|
||||
# 把日期收成 YYYY-MM-DD。空值返回空字符串。
|
||||
def iso_date(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
|
@ -53,6 +79,10 @@ def iso_date(value) -> str:
|
|||
|
||||
|
||||
def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
|
||||
"""把同一供应商一组表 3 行收成页面草稿。头字段取第一行。
|
||||
|
||||
结果里已有「只查号」时,下一班只查已有合同,不再起草。
|
||||
"""
|
||||
first = rows[0]
|
||||
sign = iso_date(first.contract_sign_date)
|
||||
total = sum(
|
||||
|
|
@ -89,4 +119,5 @@ def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
|
|||
payment_method="账期支付",
|
||||
payment_days=str(first.payment_term),
|
||||
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
|
||||
|
||||
import logging
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
|
|
@ -8,10 +14,14 @@ from contract_draft import ContractDraft
|
|||
from errors import DiligenceError, NodeError
|
||||
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 rpa_oracle import ShiftError
|
||||
|
||||
PAGE_FAILED = "PAGE_FAILED"
|
||||
Runner = Callable[[ContractDraft], ContractOutcome]
|
||||
logger = logging.getLogger("rpa_contract")
|
||||
|
||||
|
||||
# 一个合同班次的领取、合单和成败计数。
|
||||
@dataclass
|
||||
class JobSummary:
|
||||
claimed: int = 0
|
||||
|
|
@ -20,33 +30,62 @@ class JobSummary:
|
|||
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:
|
||||
"""领取待录合同行,按供应商合单、调 runner、落表。
|
||||
|
||||
session 只包领取和每一组的落表。页面在连接归还之后跑。一组落完再处理下一组。
|
||||
limit 交给领取层。
|
||||
"""
|
||||
audits: list[ShiftError] = []
|
||||
with _session(repo):
|
||||
claimed = repo.claim_pending(limit=limit)
|
||||
summary = JobSummary(claimed=len(claimed), groups=0)
|
||||
for rows in group_by_supplier(claimed):
|
||||
summary.groups += 1
|
||||
outcome = _run_group(rows, runner)
|
||||
with _session(repo):
|
||||
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():
|
||||
summary.ok += 1
|
||||
else:
|
||||
summary.failed += 1
|
||||
repo.save_shift_errors(audits)
|
||||
return summary
|
||||
|
||||
|
||||
# 录一组同一供应商的行。缺 MDM 不调页面;未捕获异常写 PAGE_FAILED。
|
||||
def _run_group(rows, runner: Runner) -> ContractOutcome:
|
||||
reason = missing_mdm_reason(rows)
|
||||
if reason:
|
||||
return ContractOutcome(KIND_FAIL, None, reason)
|
||||
try:
|
||||
outcome = runner(draft_from_group(rows))
|
||||
except Exception as exc:
|
||||
except DiligenceError:
|
||||
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
|
||||
except NodeError as 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():
|
||||
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
|
||||
return outcome
|
||||
|
||||
|
||||
# 把页面 run() 的合同号或异常收成 ContractOutcome。
|
||||
#
|
||||
# 尽调失败写客户未尽调;NodeError 写业务文案;其它异常写 PAGE_FAILED,原文放 error_text。
|
||||
def outcome_from_run(run: Callable[[], str]) -> ContractOutcome:
|
||||
try:
|
||||
number = (run() or "").strip()
|
||||
|
|
@ -55,7 +94,14 @@ def outcome_from_run(run: Callable[[], str]) -> ContractOutcome:
|
|||
except NodeError as exc:
|
||||
return ContractOutcome(KIND_FAIL, None, str(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:
|
||||
return ContractOutcome(KIND_FAIL, None, "submit 模式未读取到真实合同编号")
|
||||
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
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""合同 PDF 合并、提交占位正文。"""
|
||||
# 合同 PDF 合并、提交占位正文。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
|
@ -6,8 +7,8 @@ from pathlib import Path
|
|||
_PLACEHOLDER_PDF = Path(__file__).resolve().parent / "examples" / "placeholder_contract.pdf"
|
||||
|
||||
|
||||
# 提交必须挂合同正文;没有平台文件时用这一份占位 PDF。
|
||||
def ensure_placeholder_contract_pdf() -> str:
|
||||
"""提交必须挂合同正文;没有平台文件时用这一份占位 PDF。"""
|
||||
path = _PLACEHOLDER_PDF
|
||||
if path.is_file() and path.stat().st_size > 64:
|
||||
return str(path)
|
||||
|
|
@ -21,11 +22,8 @@ def ensure_placeholder_contract_pdf() -> str:
|
|||
return str(path)
|
||||
|
||||
|
||||
# 合成一份要上传的合同正文。同供应商同日只传这一份。单文件直接返回路径。
|
||||
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:
|
||||
raise ValueError("YYC contract creation requires at least one platform PDF")
|
||||
if len(source_paths) == 1:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""合同班结果:ContractOutcome → 表 4 行 + 表 3 状态。"""
|
||||
# 合同班结果:ContractOutcome → 表 4 行 + 表 3 状态。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -8,18 +9,26 @@ from repo import PurchaseContractRow
|
|||
KIND_OK = "ok"
|
||||
KIND_DILIGENCE = "diligence"
|
||||
KIND_FAIL = "fail"
|
||||
STAMP = "合同班"
|
||||
KIND_HOLD = "hold"
|
||||
# 提交后没读到号。下一班看到这个结果只查号,不再起草。
|
||||
LOOKUP_ONLY_RESULT = "只查号"
|
||||
USER_NAME = "系统管理员"
|
||||
TABLE4_PENDING = 0
|
||||
TABLE4_SKIP = 2
|
||||
PROCESS_RESULT_MAX = 1000
|
||||
FAIL_REASON_MAX = 500
|
||||
|
||||
|
||||
# 录合同结果。error_text 只给审计表,不进原因列。
|
||||
@dataclass(frozen=True)
|
||||
class ContractOutcome:
|
||||
kind: str
|
||||
contract_no: str | None
|
||||
reason: str | None
|
||||
error_text: str | None = None
|
||||
|
||||
|
||||
# 一组合同要写回的内容:表 3 的 fid 和状态,以及表 4 起草行。
|
||||
@dataclass(frozen=True)
|
||||
class GroupSave:
|
||||
fids: list[int]
|
||||
|
|
@ -28,6 +37,7 @@ class GroupSave:
|
|||
table4_rows: list[dict]
|
||||
|
||||
|
||||
# 原因超长时截断并加省略号,避免写爆表 3 / 表 4 的原因列。
|
||||
def clip(text: str, max_len: int) -> str:
|
||||
text = (text or "").strip()
|
||||
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:
|
||||
"""按录合同结果生成落库内容。
|
||||
|
||||
读到合同号:表 3 成功,表 4 起草行待返利领取。
|
||||
未尽调或失败:表 3 失败,表 4 记失败并不再领。
|
||||
查重多条或提交后没读到号:表 3 停在 3,不写表 4。同一 oms_id 只留一行。
|
||||
"""
|
||||
kind = outcome.kind
|
||||
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:
|
||||
process_status = 1
|
||||
process_result = "合同已提交"
|
||||
contract_status = 1
|
||||
fail_reason = None
|
||||
contract_no = number
|
||||
table4_process_status = TABLE4_PENDING
|
||||
elif kind == KIND_DILIGENCE:
|
||||
process_status = 2
|
||||
process_result = clip("客户未尽调", PROCESS_RESULT_MAX)
|
||||
contract_status = 3
|
||||
fail_reason = "客户未尽调"
|
||||
contract_no = None
|
||||
table4_process_status = TABLE4_SKIP
|
||||
else:
|
||||
reason = outcome.reason or "合同录入失败"
|
||||
if kind == KIND_OK and not number:
|
||||
|
|
@ -59,6 +84,7 @@ def build_save(rows: list[PurchaseContractRow], outcome: ContractOutcome) -> Gro
|
|||
contract_status = 3
|
||||
fail_reason = clip(reason, FAIL_REASON_MAX)
|
||||
contract_no = None
|
||||
table4_process_status = TABLE4_SKIP
|
||||
|
||||
seen: dict[int, PurchaseContractRow] = {}
|
||||
for row in rows:
|
||||
|
|
@ -75,11 +101,12 @@ def build_save(rows: list[PurchaseContractRow], outcome: ContractOutcome) -> Gro
|
|||
"agreement_id": None,
|
||||
"agreement_dtl_id": None,
|
||||
"push_status": 0,
|
||||
"process_status": table4_process_status,
|
||||
"org_code": row.org_code,
|
||||
"create_user_id": 0,
|
||||
"create_user_name": STAMP,
|
||||
"create_user_name": USER_NAME,
|
||||
"update_user_id": 0,
|
||||
"update_user_name": STAMP,
|
||||
"update_user_name": USER_NAME,
|
||||
}
|
||||
for row in seen.values()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ dev = [
|
|||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
pythonpath = [".", ".."]
|
||||
testpaths = ["tests"]
|
||||
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 collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
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
|
||||
class PurchaseContractRow:
|
||||
fid: int
|
||||
|
|
@ -38,107 +121,36 @@ class PurchaseContractRow:
|
|||
goods_mdm_code: str | None
|
||||
supplier_mdm_code: str | None
|
||||
process_result: str = ""
|
||||
process_time: datetime | None = None
|
||||
|
||||
|
||||
class MemoryContractRepo:
|
||||
def __init__(self, rows: list[PurchaseContractRow]):
|
||||
self.table3 = {row.fid: row for row in rows}
|
||||
self.table4: list[dict] = []
|
||||
self._next_fid = 1
|
||||
|
||||
def claim_pending(self, limit: int | None = None) -> list[PurchaseContractRow]:
|
||||
from group import is_claimable
|
||||
|
||||
pending = sorted(
|
||||
(row for row in self.table3.values() if is_claimable(row)),
|
||||
key=lambda row: row.fid,
|
||||
)
|
||||
if limit is not None:
|
||||
pending = pending[:limit]
|
||||
for row in pending:
|
||||
row.process_status = 3
|
||||
return pending
|
||||
|
||||
def save_group(self, payload: 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
|
||||
# 判断是否只录协议行: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
|
||||
|
||||
|
||||
_SELECT_PENDING = """
|
||||
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
|
||||
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",
|
||||
)
|
||||
# 判断内存行是否可领:0,或 3 且 process_time 已过租约。协议重录行永不领。
|
||||
def _is_claimable(row: PurchaseContractRow, now: datetime) -> bool:
|
||||
if _is_protocol_rerun(row):
|
||||
return False
|
||||
if int(row.process_status or 0) == 0:
|
||||
return True
|
||||
if int(row.process_status or 0) == 3 and row.process_time is not None:
|
||||
return now - row.process_time >= timedelta(minutes=LEASE_MINUTES)
|
||||
return False
|
||||
|
||||
|
||||
# 回滚当前事务。回滚本身失败则忽略。
|
||||
def _rollback(conn) -> None:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 把查询结果行收成 PurchaseContractRow。
|
||||
def _row_from_record(cols: list[str], rec) -> PurchaseContractRow:
|
||||
data = dict(zip(cols, rec))
|
||||
return PurchaseContractRow(
|
||||
|
|
@ -171,68 +183,168 @@ def _row_from_record(cols: list[str], rec) -> PurchaseContractRow:
|
|||
)
|
||||
|
||||
|
||||
class OracleContractRepo:
|
||||
def __init__(self, connect: Callable):
|
||||
self._connect = connect
|
||||
# 合同班次的内存仓库,测合单和班次编排,不测 SQL。
|
||||
class MemoryContractRepo:
|
||||
# 用给定的表 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
|
||||
def from_env(cls) -> "OracleContractRepo":
|
||||
oracle_dsn()
|
||||
return cls(connect=connect_from_env)
|
||||
return cls(pool=job_pool())
|
||||
|
||||
# 未捕获异常原文进共用审计表。独立事务,失败只记日志。
|
||||
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]:
|
||||
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:
|
||||
with conn.cursor() as cur:
|
||||
if limit is None:
|
||||
cur.execute(_SELECT_PENDING)
|
||||
cur.execute(_SELECT_PENDING, binds)
|
||||
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]
|
||||
fetched = [_row_from_record(cols, rec) for rec in cur.fetchall()]
|
||||
claimed: list[PurchaseContractRow] = []
|
||||
for row in fetched:
|
||||
cur.execute(_CLAIM_SQL, {"fid": row.fid})
|
||||
if cur.rowcount == 1:
|
||||
row.process_status = 3
|
||||
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},
|
||||
if fetched:
|
||||
update_by_fid_chunks(
|
||||
cur,
|
||||
_CLAIM_SQL,
|
||||
[row.fid for row in fetched],
|
||||
)
|
||||
for fid in payload.fids:
|
||||
cur.execute(
|
||||
_COMPLETE_SQL,
|
||||
for row in fetched:
|
||||
row.process_status = 3
|
||||
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_result": payload.process_result,
|
||||
"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()
|
||||
except Exception as exc:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise RepoUnavailable(str(exc)) from exc
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
_rollback(conn)
|
||||
if isinstance(exc, RepoUnavailable):
|
||||
raise
|
||||
raise RepoUnavailable("数据库不可用") from exc
|
||||
except RepoUnavailable:
|
||||
raise
|
||||
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
|
||||
|
||||
import argparse
|
||||
|
||||
from apa_browser_adapter import ApaBrowserAdapter
|
||||
from contract_create_node import CONTRACT_URL, ContractCreateInput, build_flow_context
|
||||
from contract_draft import ContractDraft
|
||||
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 persist import ContractOutcome
|
||||
from persist import KIND_DILIGENCE, KIND_HOLD, KIND_OK, LOOKUP_ONLY_RESULT, ContractOutcome
|
||||
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(
|
||||
job_id="contract-batch",
|
||||
supplier_name=draft.supplier_name,
|
||||
|
|
@ -19,19 +77,93 @@ def production_runner(browser: ApaBrowserAdapter, draft: ContractDraft) -> Contr
|
|||
source_type=draft.source_type or "",
|
||||
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:
|
||||
"""在已打开的浏览器里跑起草并返回合同号。"""
|
||||
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:
|
||||
load_runtime_env()
|
||||
def production_runner(
|
||||
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()
|
||||
if runner is None:
|
||||
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(
|
||||
f"claimed={summary.claimed} groups={summary.groups} "
|
||||
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 apa_browser_adapter import (
|
||||
ApaBrowserAdapter,
|
||||
REPO_ROOT,
|
||||
chrome_debug_port,
|
||||
resolve_chrome_profile_base,
|
||||
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():
|
||||
profile = resolve_chrome_profile_base(requested="")
|
||||
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()
|
||||
|
||||
|
||||
# RPA_CHROME_PROFILE 指到仓库内时改用仓库外默认目录。
|
||||
def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
|
||||
inside = REPO_ROOT / "rpa_contract" / "screenshots" / "chrome-profile"
|
||||
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)
|
||||
|
||||
|
||||
# RPA_CHROME_PROFILE 在仓库外时按原路径使用。
|
||||
def test_env_path_outside_repo_is_kept(tmp_path):
|
||||
outside = tmp_path / "custom-chrome"
|
||||
outside.mkdir()
|
||||
|
|
@ -30,6 +50,7 @@ def test_env_path_outside_repo_is_kept(tmp_path):
|
|||
assert profile == outside.resolve()
|
||||
|
||||
|
||||
# 同一 flow 的 profile 目录稳定,且不落在仓库内。
|
||||
def test_chrome_profile_dir_is_stable_per_flow():
|
||||
first = 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)
|
||||
|
||||
|
||||
# CONTRACT_CREATE 默认调试口 9333,同一 flow 两次相同。
|
||||
def test_chrome_debug_port_is_stable_per_flow(monkeypatch):
|
||||
monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False)
|
||||
assert chrome_debug_port("CONTRACT_CREATE") == 9333
|
||||
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:
|
||||
try:
|
||||
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"
|
||||
|
||||
|
||||
# 同供应商但货主或三个日期任一不同,不得收成同一张草稿。
|
||||
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():
|
||||
rows = [_row(supplier_mdm_code="")]
|
||||
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 errors import DiligenceError, NodeError
|
||||
from job import outcome_from_run, run_batch
|
||||
from persist import KIND_DILIGENCE, KIND_FAIL, KIND_OK, ContractOutcome, build_save
|
||||
from job import PAGE_FAILED, outcome_from_run, run_batch
|
||||
from persist import (
|
||||
KIND_DILIGENCE,
|
||||
KIND_FAIL,
|
||||
KIND_OK,
|
||||
LOOKUP_ONLY_RESULT,
|
||||
ContractOutcome,
|
||||
build_save,
|
||||
)
|
||||
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:
|
||||
data = dict(
|
||||
fid=1,
|
||||
|
|
@ -39,6 +56,7 @@ def _row(**overrides) -> PurchaseContractRow:
|
|||
return PurchaseContractRow(**data)
|
||||
|
||||
|
||||
# process_status=0 领走后变成 3,协议重录行和已完成行不领。
|
||||
def test_claim_pending_sets_processing_and_skips_protocol_rerun():
|
||||
repo = MemoryContractRepo(
|
||||
[
|
||||
|
|
@ -53,6 +71,46 @@ def test_claim_pending_sets_processing_and_skips_protocol_rerun():
|
|||
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():
|
||||
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["action_code"] 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 repo.table3[1].process_status == 1
|
||||
assert repo.table3[3].process_status == 1
|
||||
|
||||
|
||||
# 同一供应商合单一次录入,协议重录行不进本班。
|
||||
def test_job_merges_same_supplier_and_writes_table4():
|
||||
repo = MemoryContractRepo(
|
||||
[
|
||||
|
|
@ -84,6 +146,7 @@ def test_job_merges_same_supplier_and_writes_table4():
|
|||
)
|
||||
calls: list = []
|
||||
|
||||
# 记下草稿并返回成功合同号。
|
||||
def runner(draft):
|
||||
calls.append(draft)
|
||||
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["action_code"] 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 repo.table3[1].process_status == 1
|
||||
assert repo.table3[2].process_status == 1
|
||||
assert repo.table3[3].process_status == 0
|
||||
|
||||
|
||||
# 尽调失败写客户未尽调,表 3 改成 2,表 4 起草失败。
|
||||
def test_job_marks_diligence_fail():
|
||||
repo = MemoryContractRepo([_row()])
|
||||
|
||||
# 返回未尽调结果。
|
||||
def runner(draft):
|
||||
return ContractOutcome(KIND_DILIGENCE, None, "客户未尽调")
|
||||
|
||||
|
|
@ -119,14 +186,18 @@ def test_job_marks_diligence_fail():
|
|||
assert repo.table3[1].process_status == 2
|
||||
assert "客户未尽调" in repo.table3[1].process_result
|
||||
assert repo.table4[0]["contract_status"] == 3
|
||||
assert repo.table4[0]["process_status"] == 2
|
||||
assert repo.table4[0]["update_user_name"] == "系统管理员"
|
||||
assert repo.table4[0]["contract_no"] is None
|
||||
assert repo.table4[0]["contract_fail_reason"] == "客户未尽调"
|
||||
|
||||
|
||||
# 缺供应商 MDM 码时不调页面,直接业务失败。
|
||||
def test_job_skips_runner_when_mdm_missing():
|
||||
repo = MemoryContractRepo([_row(supplier_mdm_code="")])
|
||||
called = {"n": 0}
|
||||
|
||||
# 本用例不应被调用。
|
||||
def runner(draft):
|
||||
called["n"] += 1
|
||||
return ContractOutcome(KIND_OK, "HT-X", None)
|
||||
|
|
@ -137,7 +208,9 @@ def test_job_skips_runner_when_mdm_missing():
|
|||
assert "缺少供应商MDM码" in repo.table3[1].process_result
|
||||
|
||||
|
||||
# 页面尽调失败映射成 KIND_DILIGENCE。
|
||||
def test_outcome_from_run_maps_diligence():
|
||||
# 抛出未尽调。
|
||||
def run():
|
||||
raise DiligenceError("客户未尽调")
|
||||
|
||||
|
|
@ -147,18 +220,22 @@ def test_outcome_from_run_maps_diligence():
|
|||
assert out.contract_no is None
|
||||
|
||||
|
||||
# 提交成功但没读到合同号时记失败。
|
||||
def test_outcome_from_run_maps_empty_number_to_fail():
|
||||
out = outcome_from_run(lambda: "")
|
||||
assert out.kind == KIND_FAIL
|
||||
assert "未读取到真实合同编号" in (out.reason or "")
|
||||
|
||||
|
||||
# 读到合同号时记成功。
|
||||
def test_outcome_from_run_ok():
|
||||
out = outcome_from_run(lambda: "HT9")
|
||||
assert out == ContractOutcome(KIND_OK, "HT9", None)
|
||||
|
||||
|
||||
# 页面 NodeError 仍写业务文案。
|
||||
def test_outcome_from_run_maps_node_error():
|
||||
# 抛出客商加载失败。
|
||||
def run():
|
||||
raise NodeError("客商加载失败")
|
||||
|
||||
|
|
@ -166,3 +243,218 @@ def test_outcome_from_run_maps_node_error():
|
|||
assert out.kind == KIND_FAIL
|
||||
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`,请求里不跑选品
|
||||
- 接口 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
|
||||
from rpa_oracle.pool import create_pool
|
||||
from app.outbound.cart_settlement import load_cart_settlement_envelope
|
||||
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
|
||||
from rpa_oracle.pool import create_pool
|
||||
from app.outbound.contract_agreement import load_contract_agreement_envelope
|
||||
from app.outbound.oracle_repo import OracleContractAgreementRepo
|
||||
|
||||
envelope = load_contract_agreement_envelope(OracleContractAgreementRepo.from_env())
|
||||
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" `
|
||||
--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
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
|
||||
# 比较请求头与环境变量里的共享密钥。两边有空就失败。
|
||||
def token_ok(provided: str | None) -> bool:
|
||||
expected = os.getenv("OMS_INBOUND_TOKEN", "")
|
||||
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,一条都不落。
|
||||
|
||||
单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。
|
||||
"""
|
||||
# 批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。
|
||||
#
|
||||
# 单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。
|
||||
|
||||
MAX_BATCH = 100
|
||||
|
||||
|
||||
# 信封不合法,对应 HTTP 422。
|
||||
class EnvelopeError(Exception):
|
||||
# 记下对外文案。
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
|
||||
|
||||
# 拆出 data 数组。不合法时抛 EnvelopeError,调用方整包拒绝。
|
||||
def parse_envelope(payload: object) -> list:
|
||||
if not isinstance(payload, dict) or "data" not in payload:
|
||||
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
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from rpa_oracle.errors import RepoUnavailable, is_contract_fid_duplicate
|
||||
|
||||
from .oracle_repo import _ensure_client, _ora_code
|
||||
from .repo import DuplicateFid, RepoUnavailable
|
||||
from .oracle_repo import _rollback, in_binds, unique_collisions
|
||||
|
||||
_INSERT_SQL = """
|
||||
INSERT INTO rpa_purchase_contract_data (
|
||||
|
|
@ -60,44 +59,51 @@ _BIND_KEYS = (
|
|||
)
|
||||
|
||||
|
||||
# 接口 3 连 Oracle 的仓库,管表 3 按 fid 查重和批量插入。
|
||||
#
|
||||
# 连接由调用方从池里借来,一个请求一条。
|
||||
class OraclePurchaseContractRepo:
|
||||
def __init__(self, connect: Callable):
|
||||
self._connect = connect
|
||||
# 挂上本请求的连接。不要在仓库里再 connect。
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OraclePurchaseContractRepo":
|
||||
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 insert(self, record: dict) -> None:
|
||||
conn = self._connect()
|
||||
# 查出表 3 里已经有的 fid。空列表不访问库。
|
||||
def existing_fids(self, fids: list[int]) -> set[int]:
|
||||
if not fids:
|
||||
return set()
|
||||
placeholders, binds = in_binds(fids)
|
||||
sql = (
|
||||
"SELECT fid FROM rpa_purchase_contract_data "
|
||||
f"WHERE fid IN ({placeholders})"
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_INSERT_SQL, {key: record[key] for key in _BIND_KEYS})
|
||||
conn.commit()
|
||||
except DuplicateFid:
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(sql, binds)
|
||||
return {row[0] for row in cur.fetchall()}
|
||||
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
|
||||
except Exception as exc:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if _ora_code(exc) == 1:
|
||||
raise DuplicateFid(record["fid"]) from exc
|
||||
raise RepoUnavailable(str(exc)) from exc
|
||||
finally:
|
||||
conn.close()
|
||||
_rollback(self._conn)
|
||||
if is_contract_fid_duplicate(exc) and len(records) == 1:
|
||||
return {records[0]["fid"]}
|
||||
raise RepoUnavailable("数据库不可用") from exc
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from rpa_oracle.errors import RepoUnavailable, is_sales_oms_id_duplicate
|
||||
|
||||
from .repo import DuplicateOmsId, RepoUnavailable
|
||||
|
||||
# 接口入站无登录用户:创建人/更新人固定 0 / 系统管理员,时间用库时钟。
|
||||
_INSERT_SQL = """
|
||||
INSERT INTO rpa_sales_goods_mapping_data (
|
||||
oms_id, bar_code, order_date, sale_price, sale_qty, plan_purchase_qty,
|
||||
|
|
@ -50,67 +46,90 @@ _BIND_KEYS = (
|
|||
"customer_mdm_code",
|
||||
)
|
||||
|
||||
_client_ready = False
|
||||
|
||||
|
||||
# 接口 1 连 Oracle 的仓库,管表 1 查重和批量插入。
|
||||
#
|
||||
# 连接由调用方从池里借来,一个请求一条。
|
||||
class OracleSalesGoodsRepo:
|
||||
def __init__(self, connect: Callable):
|
||||
self._connect = connect
|
||||
# 挂上本请求的连接。不要在仓库里再 connect。
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OracleSalesGoodsRepo":
|
||||
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 insert(self, record: dict) -> None:
|
||||
conn = self._connect()
|
||||
# 查出表 1 里已经有的 oms_id。空列表不访问库。
|
||||
def existing_oms_ids(self, oms_ids: list[int]) -> set[int]:
|
||||
if not oms_ids:
|
||||
return set()
|
||||
placeholders, binds = in_binds(oms_ids)
|
||||
sql = (
|
||||
"SELECT oms_id FROM rpa_sales_goods_mapping_data "
|
||||
f"WHERE oms_id IN ({placeholders})"
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_INSERT_SQL, {key: record[key] for key in _BIND_KEYS})
|
||||
conn.commit()
|
||||
except DuplicateOmsId:
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(sql, binds)
|
||||
return {row[0] for row in cur.fetchall()}
|
||||
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
|
||||
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:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
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 datetime import date, datetime
|
||||
|
||||
from .repo import DuplicateFid
|
||||
from .sales_goods import _parse_decimal, _parse_int, _parse_oms_id, _parse_str
|
||||
from . import messages
|
||||
from .parse import parse_date, parse_decimal, parse_fid, parse_int, parse_oms_id, parse_str
|
||||
|
||||
_STRING_FIELDS = (
|
||||
("supplierId", "supplier_id", 20),
|
||||
|
|
@ -41,21 +39,30 @@ _DECIMAL_FIELDS = (
|
|||
)
|
||||
|
||||
|
||||
# 整批接收采购合同:先校验再落库,返回与入参下标对齐的结果。
|
||||
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()
|
||||
results: list[dict] = []
|
||||
for row in rows:
|
||||
results.append(_receive_one(row, repo, seen))
|
||||
return results
|
||||
for index, row in enumerate(rows):
|
||||
decided = _classify(row, seen)
|
||||
if "resultCode" in decided:
|
||||
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):
|
||||
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:
|
||||
return _result(fid, "CONFLICT", "同一请求中 fid 重复")
|
||||
return _result(fid, "CONFLICT", messages.CONTRACT_CONFLICT)
|
||||
if fid is not None:
|
||||
seen.add(fid)
|
||||
|
||||
|
|
@ -63,32 +70,52 @@ def _receive_one(row: object, repo, seen: set[int]) -> dict:
|
|||
if error:
|
||||
_camel, message = error
|
||||
return _result(row.get("fid"), "REJECTED", message)
|
||||
|
||||
try:
|
||||
repo.insert(parsed)
|
||||
except DuplicateFid:
|
||||
return _result(parsed["fid"], "DUPLICATE", "序号已存在,未重复入库")
|
||||
|
||||
return _result(parsed["fid"], "ACCEPTED", "采购合同数据已接收,等待处理")
|
||||
return parsed
|
||||
|
||||
|
||||
# 对通过校验的行一次查重、一次插入,把 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):
|
||||
if 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:
|
||||
return None, ("omsId", oms_error)
|
||||
|
||||
record: dict = {"fid": fid, "oms_id": oms_id, "process_status": 0}
|
||||
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:
|
||||
return None, (camel, error)
|
||||
record[snake] = value
|
||||
|
||||
for camel, snake in _DATE_FIELDS:
|
||||
value, error = _parse_date(row.get(camel), camel)
|
||||
value, error = parse_date(row.get(camel), camel)
|
||||
if error:
|
||||
return None, (camel, error)
|
||||
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"]:
|
||||
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:
|
||||
return None, ("paymentTerm", error)
|
||||
record["payment_term"] = payment_term
|
||||
|
||||
for camel, snake in _DECIMAL_FIELDS:
|
||||
value, error = _parse_decimal(row.get(camel), camel)
|
||||
value, error = parse_decimal(row.get(camel), camel)
|
||||
if error:
|
||||
return None, (camel, error)
|
||||
record[snake] = value
|
||||
|
||||
customer_type, error = _parse_int(row.get("customerType"), "customerType")
|
||||
customer_type, error = parse_int(row.get("customerType"), "customerType")
|
||||
if error:
|
||||
return None, ("customerType", error)
|
||||
record["customer_type"] = customer_type
|
||||
|
|
@ -125,29 +152,7 @@ def _parse_row(row: dict, fid: int | None, fid_error: str | None):
|
|||
return record, None
|
||||
|
||||
|
||||
def _parse_fid(value) -> tuple[int | None, str | None]:
|
||||
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"
|
||||
|
||||
|
||||
# 录入合同状态:缺省当 None;有值必须是 0 或 1。
|
||||
def _parse_entry_status(value) -> tuple[int | None, str | None]:
|
||||
if value is None or value == "":
|
||||
return None, None
|
||||
|
|
@ -156,13 +161,15 @@ def _parse_entry_status(value) -> tuple[int | None, str | None]:
|
|||
return None, "contractEntryStatus 必须是 0 或 1"
|
||||
|
||||
|
||||
# contractEntryStatus=0 时合同号必填;其它情况可空。
|
||||
def _parse_contract_no(value, entry_status: int | None) -> tuple[str | None, str | None]:
|
||||
if entry_status == 0:
|
||||
return _parse_str(value, "contractNo", 40)
|
||||
return parse_str(value, "contractNo", 40)
|
||||
if value is None or value == "":
|
||||
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:
|
||||
return {"fid": fid, "resultCode": result_code, "message": message}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
"""收件仓库契约。
|
||||
# 收件仓库契约。
|
||||
#
|
||||
# DuplicateOmsId / DuplicateFid:幂等键已存在时的旧异常类型,班次和出站仍可能引用。
|
||||
# RepoUnavailable 与 rpa_oracle.errors 是同一个类:连不上库或未配置,整包 HTTP 503。
|
||||
|
||||
DuplicateOmsId:oms_id 已存在,调用方回 DUPLICATE,不更新。
|
||||
DuplicateFid:接口 3 来源 fid 已存在,调用方回 DUPLICATE,不更新。
|
||||
RepoUnavailable:连不上库或未配置,整包 HTTP 503,一条都不落。
|
||||
"""
|
||||
from rpa_oracle.errors import RepoUnavailable
|
||||
|
||||
|
||||
# 表 1 已有同一 oms_id。新入站路径用查重集合,不再抛这个。
|
||||
class DuplicateOmsId(Exception):
|
||||
# 记下冲突的 oms_id。
|
||||
def __init__(self, oms_id: int):
|
||||
self.oms_id = oms_id
|
||||
|
||||
|
||||
# 表 3 已有同一 fid。新入站路径用查重集合,不再抛这个。
|
||||
class DuplicateFid(Exception):
|
||||
# 记下冲突的 fid。
|
||||
def __init__(self, fid: int):
|
||||
self.fid = fid
|
||||
|
||||
|
||||
class RepoUnavailable(Exception):
|
||||
def __init__(self, message: str = "数据库不可用"):
|
||||
self.message = message
|
||||
__all__ = ["DuplicateFid", "DuplicateOmsId", "RepoUnavailable"]
|
||||
|
|
|
|||
|
|
@ -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 datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from . import messages
|
||||
from .parse import parse_date, parse_decimal, parse_int, parse_oms_id, parse_str
|
||||
|
||||
from .repo import DuplicateOmsId
|
||||
|
||||
# (接口字段, 表列, 最大字符数)
|
||||
_STRING_FIELDS = (
|
||||
("barCode", "bar_code", 40),
|
||||
("goodsId", "goods_id", 20),
|
||||
|
|
@ -39,21 +37,30 @@ _INT_FIELDS = (
|
|||
)
|
||||
|
||||
|
||||
# 整批接收销售商品:先校验再落库,返回与入参下标对齐的结果。
|
||||
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()
|
||||
results: list[dict] = []
|
||||
for row in rows:
|
||||
results.append(_receive_one(row, repo, seen))
|
||||
return results
|
||||
for index, row in enumerate(rows):
|
||||
decided = _classify(row, seen)
|
||||
if "resultCode" in decided:
|
||||
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):
|
||||
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:
|
||||
return _result(oms_id, "CONFLICT", "同一请求中 omsId 重复")
|
||||
return _result(oms_id, "CONFLICT", messages.SALES_CONFLICT)
|
||||
if oms_id is not None:
|
||||
seen.add(oms_id)
|
||||
|
||||
|
|
@ -61,38 +68,58 @@ def _receive_one(row: object, repo, seen: set[int]) -> dict:
|
|||
if error:
|
||||
_camel, message = error
|
||||
return _result(row.get("omsId"), "REJECTED", message)
|
||||
|
||||
try:
|
||||
repo.insert(parsed)
|
||||
except DuplicateOmsId:
|
||||
return _result(parsed["oms_id"], "DUPLICATE", "订单中心序号已存在,未重复入库")
|
||||
|
||||
return _result(parsed["oms_id"], "ACCEPTED", "接收成功,等待RPA处理")
|
||||
return parsed
|
||||
|
||||
|
||||
# 对通过校验的行一次查重、一次插入,把 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):
|
||||
if oms_error:
|
||||
return None, ("omsId", oms_error)
|
||||
|
||||
record: dict = {"oms_id": oms_id, "deal_flag": 0}
|
||||
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:
|
||||
return None, (camel, error)
|
||||
record[snake] = value
|
||||
|
||||
order_date, error = _parse_date(row.get("orderDate"))
|
||||
order_date, error = parse_date(row.get("orderDate"), "orderDate")
|
||||
if error:
|
||||
return None, ("orderDate", error)
|
||||
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:
|
||||
return None, ("salePrice", error)
|
||||
record["sale_price"] = sale_price
|
||||
|
||||
for camel, snake in _INT_FIELDS:
|
||||
value, error = _parse_int(row.get(camel), camel)
|
||||
value, error = parse_int(row.get(camel), camel)
|
||||
if error:
|
||||
return None, (camel, error)
|
||||
record[snake] = value
|
||||
|
|
@ -100,71 +127,6 @@ def _parse_row(row: dict, oms_id: int | None, oms_error: str | None):
|
|||
return record, None
|
||||
|
||||
|
||||
def _parse_oms_id(value) -> tuple[int | None, str | None]:
|
||||
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"
|
||||
|
||||
|
||||
# 拼一条接口 1 的 results[] 项。
|
||||
def _result(oms_id, result_code: str, message: str) -> dict:
|
||||
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
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Header
|
||||
from fastapi import Depends, FastAPI, Header, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .auth import token_ok
|
||||
from .local_env import load_local_env
|
||||
# 把仓库根目录放进 sys.path,才能 import rpa_oracle。
|
||||
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.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.repo import RepoUnavailable
|
||||
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")
|
||||
|
||||
|
||||
def get_repo():
|
||||
# 运行时只走 Oracle。单测用 dependency_overrides 注入内存替身。
|
||||
from .inbound.oracle_repo import OracleSalesGoodsRepo
|
||||
|
||||
return OracleSalesGoodsRepo.from_env()
|
||||
_pool = None
|
||||
_pool_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_contract_repo():
|
||||
from .inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
||||
# 进程内共用 OMS 连接池。缺配置抛「数据库不可用」。
|
||||
#
|
||||
# 懒加载加锁:并发的首个请求只建一个池,输家的池不会被丢掉漏连接。
|
||||
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():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/oms/v1/sales-goods")
|
||||
@app.post("/api/oms/v1/sales-goods", description="接收接口 1 销售商品,落表 1。请求里不跑选品。")
|
||||
def post_sales_goods(
|
||||
payload: dict[str, Any] | None = None,
|
||||
x_oms_token: Annotated[str | None, Header()] = None,
|
||||
repo=Depends(get_repo),
|
||||
pool=Depends(get_pool),
|
||||
):
|
||||
return _post_inbound(
|
||||
payload,
|
||||
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(
|
||||
payload: dict[str, Any] | None = None,
|
||||
x_oms_token: Annotated[str | None, Header()] = None,
|
||||
repo=Depends(get_contract_repo),
|
||||
pool=Depends(get_pool),
|
||||
):
|
||||
return _post_inbound(
|
||||
payload,
|
||||
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):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"code": 401, "msg": "身份认证失败", "results": []},
|
||||
)
|
||||
body = {"code": 401, "msg": AUTH_FAILED, "results": []}
|
||||
_audit_best_effort(pool, api_no, 401, payload, body, None, x_oms_token)
|
||||
return JSONResponse(status_code=401, content=body)
|
||||
try:
|
||||
rows = parse_envelope(payload if payload is not None else {})
|
||||
except EnvelopeError as exc:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"code": 422, "msg": exc.message, "results": []},
|
||||
)
|
||||
body = {"code": 422, "msg": exc.message, "results": []}
|
||||
_audit_best_effort(pool, api_no, 422, payload, body, None, x_oms_token)
|
||||
return JSONResponse(status_code=422, content=body)
|
||||
try:
|
||||
results = receive(rows)
|
||||
except RepoUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"code": 503, "msg": exc.message, "results": []},
|
||||
with pool.acquire() as conn:
|
||||
results = receive(rows, repo_cls(conn))
|
||||
body = {"code": 200, "msg": _summary(results), "results": results}
|
||||
write_audit(
|
||||
conn,
|
||||
api_no=api_no,
|
||||
http_code=200,
|
||||
payload=payload,
|
||||
response=body,
|
||||
error_text=None,
|
||||
token=x_oms_token,
|
||||
)
|
||||
# 逐条有成功/重复/拒绝时 HTTP 仍 200,看 results[].resultCode。
|
||||
return {
|
||||
"code": 200,
|
||||
"msg": _summary(results),
|
||||
"results": results,
|
||||
}
|
||||
return body
|
||||
except RepoUnavailable as exc:
|
||||
body = {"code": 503, "msg": DB_UNAVAILABLE, "results": []}
|
||||
_audit_best_effort(
|
||||
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:
|
||||
codes = {item["resultCode"] for item in results}
|
||||
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 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:
|
||||
return build_cart_settlement_envelope(repo.list_unpushed(), limit=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:
|
||||
chosen = _take_complete_groups(rows, limit)
|
||||
return {"data": [_to_item(row) for row in chosen]}
|
||||
|
||||
|
||||
# 按 oms_id 保序成组;装不下的整组留下。空批时超限组仍进入。
|
||||
def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
||||
groups: OrderedDict = OrderedDict()
|
||||
for row in rows:
|
||||
|
|
@ -32,31 +43,28 @@ def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
|||
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:
|
||||
return {
|
||||
"fid": _as_int(row["fid"]),
|
||||
"operateType": _as_int(row["operate_type"]),
|
||||
"omsId": _as_int(row["oms_id"]),
|
||||
"platformType": _as_int(row["platform_type"]),
|
||||
"fid": as_int(row["fid"]),
|
||||
"operateType": as_int(row["operate_type"]),
|
||||
"omsId": as_int(row["oms_id"]),
|
||||
"platformType": as_int(row["platform_type"]),
|
||||
"supplierName": row["supplier_name"],
|
||||
"barCode": row["bar_code"],
|
||||
"purchasePrice": _money4(row["purchase_price"]),
|
||||
"purchaseQty": _as_int(row["purchase_qty"]),
|
||||
"purchaseTotal": _money4(row["purchase_total"]),
|
||||
"status": _as_int(row["status"]) if row.get("status") is not None else None,
|
||||
"purchasePrice": _money4(row.get("purchase_price")),
|
||||
"purchaseQty": as_int(row["purchase_qty"]),
|
||||
"purchaseTotal": _money4(row.get("purchase_total")),
|
||||
"status": as_int(row["status"]) if row.get("status") is not None else None,
|
||||
"failReason": row.get("fail_reason"),
|
||||
"payOrderNo": row.get("pay_order_no"),
|
||||
"payOrderDtlId": row.get("pay_order_dtl_id"),
|
||||
"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 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:
|
||||
return build_contract_agreement_envelope(repo.list_unpushed(), limit=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:
|
||||
chosen = _take_complete_groups(rows, limit)
|
||||
return {"data": [_to_item(row) for row in chosen]}
|
||||
|
||||
|
||||
# 非空合同号成一组;空合同号用 fid 自成一组。
|
||||
def _group_key(row: dict):
|
||||
number = row.get("contract_no")
|
||||
if number is None or str(number).strip() == "":
|
||||
|
|
@ -26,6 +38,7 @@ def _group_key(row: dict):
|
|||
return ("no", str(number).strip())
|
||||
|
||||
|
||||
# 按合同号保序成组;装不下的整组留下。空批时超限组仍进入。
|
||||
def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
||||
groups: OrderedDict = OrderedDict()
|
||||
for row in rows:
|
||||
|
|
@ -38,32 +51,18 @@ def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]:
|
|||
return batch
|
||||
|
||||
|
||||
# 把表 4 行转成接口 4 文档字段。
|
||||
def _to_item(row: dict) -> dict:
|
||||
return {
|
||||
"fid": _as_int(row["fid"]),
|
||||
"omsId": _as_int(row["oms_id"]),
|
||||
"actionCode": _as_int(row["action_code"]),
|
||||
"contractStatus": _as_int(row.get("contract_status")),
|
||||
"contractFailReason": _as_str(row.get("contract_fail_reason")),
|
||||
"contractNo": _as_str(row.get("contract_no")),
|
||||
"agreementStatus": _as_int(row.get("agreement_status")),
|
||||
"agreementFailReason": _as_str(row.get("agreement_fail_reason")),
|
||||
"agreementId": _as_str(row.get("agreement_id")),
|
||||
"agreementDtlId": _as_str(row.get("agreement_dtl_id")),
|
||||
"orgCode": _as_str(row.get("org_code")),
|
||||
"fid": as_int(row["fid"]),
|
||||
"omsId": as_int(row["oms_id"]),
|
||||
"actionCode": as_int(row["action_code"]),
|
||||
"contractStatus": as_int(row.get("contract_status")),
|
||||
"contractFailReason": as_str(row.get("contract_fail_reason")),
|
||||
"contractNo": as_str(row.get("contract_no")),
|
||||
"agreementStatus": as_int(row.get("agreement_status")),
|
||||
"agreementFailReason": as_str(row.get("agreement_fail_reason")),
|
||||
"agreementId": as_str(row.get("agreement_id")),
|
||||
"agreementDtlId": as_str(row.get("agreement_dtl_id")),
|
||||
"orgCode": as_str(row.get("org_code")),
|
||||
}
|
||||
|
||||
|
||||
def _as_int(value):
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _as_str(value):
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if text.strip() == "":
|
||||
return None
|
||||
return text
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from rpa_oracle.errors import RepoUnavailable
|
||||
|
||||
from ..inbound.oracle_repo import _ensure_client
|
||||
from ..inbound.repo import RepoUnavailable
|
||||
from ..inbound.oracle_repo import in_binds
|
||||
|
||||
_LIST_UNPUSHED_SQL = """
|
||||
SELECT
|
||||
WITH src AS (
|
||||
SELECT
|
||||
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
|
||||
FROM rpa_cart_settlement_goods_data
|
||||
WHERE push_status = 0
|
||||
ORDER BY oms_id, fid
|
||||
FROM rpa_cart_settlement_goods_data
|
||||
WHERE push_status = 0
|
||||
),
|
||||
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 = """
|
||||
SELECT
|
||||
WITH src AS (
|
||||
SELECT
|
||||
fid, oms_id, action_code, contract_status, contract_fail_reason,
|
||||
contract_no, agreement_status, agreement_fail_reason,
|
||||
agreement_id, agreement_dtl_id, org_code, push_status
|
||||
FROM rpa_contract_agreement_result
|
||||
WHERE push_status = 0
|
||||
ORDER BY fid
|
||||
agreement_id, agreement_dtl_id, org_code, push_status,
|
||||
CASE
|
||||
WHEN TRIM(contract_no) IS NULL THEN 'F:' || TO_CHAR(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:
|
||||
def __init__(self, connect: Callable):
|
||||
self._connect = connect
|
||||
# 挂上调用方借来的连接。不要在仓库里再 connect 或 close。
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OracleContractAgreementRepo":
|
||||
user = os.getenv("RPA_ORACLE_USER", "")
|
||||
password = os.getenv("RPA_ORACLE_PASSWORD", "")
|
||||
host = os.getenv("RPA_ORACLE_HOST", "")
|
||||
if not user or not password or not host:
|
||||
raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD")
|
||||
port = os.getenv("RPA_ORACLE_PORT", "1521")
|
||||
service = os.getenv("RPA_ORACLE_SERVICE", "rpa")
|
||||
dsn = f"{host}:{port}/{service}"
|
||||
# 捞 push_status=0,同一非空 contract_no 不拆组,在 SQL 侧按 limit 截断。
|
||||
def list_unpushed(self, limit: int = 500) -> list[dict]:
|
||||
return _fetch(self._conn, _LIST_AGREEMENT_SQL, {"limit": limit})
|
||||
|
||||
def connect():
|
||||
_ensure_client()
|
||||
import oracledb
|
||||
# 一次 UPDATE 回写表 4 的 push_status。空列表不访问库。
|
||||
def mark_pushed(self, fids: list[int], status: int, reason: str | None) -> None:
|
||||
_mark(self._conn, _AGREEMENT_TABLE, fids, status, reason, "SYSTIMESTAMP")
|
||||
|
||||
return oracledb.connect(user=user, password=password, dsn=dsn)
|
||||
|
||||
return cls(connect=connect)
|
||||
|
||||
def list_unpushed(self) -> list[dict]:
|
||||
conn = self._connect()
|
||||
# 执行查询并把列名转小写。失败抛「数据库不可用」,不含 ORA。
|
||||
def _fetch(conn, sql: str, binds: dict) -> list[dict]:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_LIST_AGREEMENT_SQL)
|
||||
cur.execute(sql, binds)
|
||||
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()
|
||||
raise RepoUnavailable("数据库不可用") from exc
|
||||
|
||||
|
||||
# 一次 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
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
pythonpath = [".", ".."]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
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 decimal import Decimal
|
||||
|
||||
from app.outbound.cart_settlement import (
|
||||
MAX_BATCH,
|
||||
batch_limit,
|
||||
build_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):
|
||||
row = {
|
||||
"fid": 100001,
|
||||
|
|
@ -34,8 +38,25 @@ def _row(**overrides):
|
|||
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():
|
||||
repo = MemoryCartSettlementRepo(
|
||||
repo, db = _open(
|
||||
[
|
||||
_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():
|
||||
repo = MemoryCartSettlementRepo(
|
||||
repo, db = _open(
|
||||
[
|
||||
_row(fid=1, push_status=1),
|
||||
_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)
|
||||
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():
|
||||
rows = [
|
||||
_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]
|
||||
|
||||
|
||||
# 批次已有行时,装不下的 omsId 整组留下。
|
||||
def test_does_not_split_oms_id_group_across_batch_limit():
|
||||
rows = [
|
||||
_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]
|
||||
|
||||
|
||||
# 默认上限 500;超过则按组截到 500。
|
||||
def test_batch_cap_is_500():
|
||||
assert MAX_BATCH == 500
|
||||
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
|
||||
|
||||
|
||||
# 报文不含 push_status 等表内列。
|
||||
def test_payload_omits_table_only_columns():
|
||||
envelope = build_cart_settlement_envelope([_row()])
|
||||
item = envelope["data"][0]
|
||||
|
|
@ -135,3 +191,47 @@ def test_payload_omits_table_only_columns():
|
|||
assert "push_status" not in item
|
||||
assert "multiPlatformFlag" 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 app.outbound.contract_agreement import (
|
||||
MAX_BATCH,
|
||||
batch_limit,
|
||||
build_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):
|
||||
row = {
|
||||
"fid": 300001,
|
||||
|
|
@ -28,8 +32,25 @@ def _row(**overrides):
|
|||
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():
|
||||
repo = MemoryContractAgreementRepo(
|
||||
repo, db = _open(
|
||||
[
|
||||
_row(),
|
||||
_row(
|
||||
|
|
@ -76,10 +97,15 @@ def test_assembles_unpushed_rows_as_document_envelope():
|
|||
]
|
||||
}
|
||||
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():
|
||||
repo = MemoryContractAgreementRepo(
|
||||
repo, db = _open(
|
||||
[
|
||||
_row(contract_status=2),
|
||||
_row(
|
||||
|
|
@ -100,8 +126,11 @@ def test_assembles_contract_success_and_agreement_rows():
|
|||
assert envelope["data"][1]["agreementStatus"] == 1
|
||||
assert envelope["data"][1]["agreementId"] == "AGR1"
|
||||
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():
|
||||
rows = [
|
||||
_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]
|
||||
|
||||
|
||||
# 批次已有行时,装不下的 contractNo 整组留下。
|
||||
def test_does_not_split_contract_no_when_batch_already_has_rows():
|
||||
rows = [
|
||||
_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]
|
||||
|
||||
|
||||
# 空 contractNo 按行独立,互不捆绑。
|
||||
def test_empty_contract_no_rows_are_independent():
|
||||
rows = [
|
||||
_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]
|
||||
|
||||
|
||||
# 默认上限 500。
|
||||
def test_batch_cap_is_500():
|
||||
assert MAX_BATCH == 500
|
||||
|
||||
|
||||
# 报文不含 push_status。
|
||||
def test_payload_omits_push_status():
|
||||
item = build_contract_agreement_envelope([_row()])["data"][0]
|
||||
assert "pushStatus" not in item
|
||||
assert "push_status" not in item
|
||||
|
||||
|
||||
# 组装入口把 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
|
||||
|
||||
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):
|
||||
monkeypatch.setenv("OMS_REPO", "memory")
|
||||
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
|
||||
|
||||
import pytest
|
||||
|
||||
from app.inbound.repo import RepoUnavailable
|
||||
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():
|
||||
sql = " ".join(_LIST_UNPUSHED_SQL.split())
|
||||
assert "rpa_cart_settlement_goods_data" in sql.lower()
|
||||
# 构造一条表 2 未推行。
|
||||
def _cart(**overrides) -> dict:
|
||||
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 "order by oms_id" in sql.lower()
|
||||
assert "fid" in sql.lower()
|
||||
assert "partition by" in sql
|
||||
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():
|
||||
class FakeCursor:
|
||||
description = (
|
||||
("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",),
|
||||
db = FakeDb()
|
||||
db.carts[100001] = _cart(
|
||||
fid=100001,
|
||||
oms_id=20260928000001,
|
||||
bar_code="06901234567890",
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
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()
|
||||
repo = OracleCartSettlementRepo(db.connection())
|
||||
rows = repo.list_unpushed(limit=500)
|
||||
assert rows[0]["fid"] == 100001
|
||||
assert rows[0]["operate_type"] == 1
|
||||
assert rows[0]["bar_code"] == "06901234567890"
|
||||
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 datetime import date
|
||||
|
|
@ -6,11 +7,13 @@ from decimal import Decimal
|
|||
|
||||
import pytest
|
||||
|
||||
from app.inbound.repo import DuplicateFid, RepoUnavailable
|
||||
from app.inbound.repo import RepoUnavailable
|
||||
from tests.fake_oracle import FakeDb
|
||||
|
||||
|
||||
def _record():
|
||||
return {
|
||||
# 构造一条表 3 落库记录。
|
||||
def _record(**overrides):
|
||||
row = {
|
||||
"fid": 200001,
|
||||
"oms_id": 20260928000001,
|
||||
"supplier_id": "SUP001",
|
||||
|
|
@ -40,51 +43,61 @@ def _record():
|
|||
"supplier_mdm_code": "DW0100046361",
|
||||
"process_status": 0,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
# 缺 host 时 get_pool 抛「数据库不可用」。
|
||||
def test_from_env_without_host_raises(monkeypatch):
|
||||
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
|
||||
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
||||
import app.main as main
|
||||
|
||||
with pytest.raises(RepoUnavailable):
|
||||
OraclePurchaseContractRepo.from_env()
|
||||
main._pool = None
|
||||
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():
|
||||
from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo
|
||||
|
||||
class FakeError(Exception):
|
||||
def __init__(self):
|
||||
self.args = (type("Info", (), {"code": 1})(),)
|
||||
|
||||
class FakeCursor:
|
||||
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())
|
||||
db = FakeDb()
|
||||
db.collide_contracts.add(200001)
|
||||
repo = OraclePurchaseContractRepo(db.connection())
|
||||
collided = repo.insert_many([_record()])
|
||||
assert collided == {200001}
|
||||
|
||||
|
||||
# 未知唯一约束不当成 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():
|
||||
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
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OMS_INBOUND_TOKEN", "test-oms-token")
|
||||
|
||||
from app.inbound.repo import DuplicateOmsId, RepoUnavailable
|
||||
from app.inbound.repo import RepoUnavailable
|
||||
from tests.fake_oracle import FakeDb, ora_error
|
||||
|
||||
|
||||
def _record():
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
return {
|
||||
# 构造一条表 1 落库记录。
|
||||
def _record(**overrides):
|
||||
row = {
|
||||
"oms_id": 2026092801,
|
||||
"bar_code": "06901234567890",
|
||||
"order_date": date(2026, 9, 28),
|
||||
|
|
@ -34,52 +33,109 @@ def _record():
|
|||
"customer_mdm_code": "DW0100079727",
|
||||
"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):
|
||||
monkeypatch.delenv("RPA_ORACLE_USER", 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):
|
||||
OracleSalesGoodsRepo.from_env()
|
||||
main._pool = None
|
||||
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():
|
||||
from app.inbound.oracle_repo import OracleSalesGoodsRepo
|
||||
|
||||
class FakeError(Exception):
|
||||
def __init__(self):
|
||||
self.args = (type("Info", (), {"code": 1})(),)
|
||||
|
||||
class FakeCursor:
|
||||
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())
|
||||
db = FakeDb()
|
||||
db.collide_sales.add(2026092801)
|
||||
repo = OracleSalesGoodsRepo(db.connection())
|
||||
collided = repo.insert_many([_record()])
|
||||
assert collided == {2026092801}
|
||||
|
||||
|
||||
# 未知唯一约束不当成 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():
|
||||
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
|
||||
|
||||
|
||||
# 创建人/更新人固定 0 / 系统管理员,时间用 SYSDATE。
|
||||
def test_insert_sql_sets_system_auditor():
|
||||
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 "SYSDATE, 0, '系统管理员'" in sql
|
||||
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
|
||||
|
||||
import os
|
||||
|
|
@ -8,14 +9,14 @@ from fastapi.testclient import TestClient
|
|||
|
||||
os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token"
|
||||
|
||||
from app.inbound.memory_repo import MemoryPurchaseContractRepo
|
||||
from app.inbound.repo import RepoUnavailable
|
||||
from app.main import app, get_contract_repo
|
||||
from app.main import app, get_pool
|
||||
from tests.fake_oracle import FakePool, ora_error
|
||||
|
||||
TOKEN = "test-oms-token"
|
||||
PATH = "/api/oms/v1/purchase-contracts"
|
||||
|
||||
|
||||
# 构造一条合法的接口 3 明细,overrides 覆盖字段。
|
||||
def _item(**overrides):
|
||||
row = {
|
||||
"fid": 200001,
|
||||
|
|
@ -50,44 +51,51 @@ def _item(**overrides):
|
|||
return row
|
||||
|
||||
|
||||
# 注入 FakePool,整次请求走同一块假库。
|
||||
@pytest.fixture
|
||||
def client():
|
||||
repo = MemoryPurchaseContractRepo()
|
||||
app.dependency_overrides[get_contract_repo] = lambda: repo
|
||||
pool = FakePool()
|
||||
app.dependency_overrides[get_pool] = lambda: pool
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client, repo
|
||||
yield test_client, pool
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# 带或不带 X-Oms-Token 发 POST。
|
||||
def _post(client, body, token=TOKEN):
|
||||
headers = {"X-Oms-Token": token} if token is not None else {}
|
||||
return client.post(PATH, json=body, headers=headers)
|
||||
|
||||
|
||||
# 缺 X-Oms-Token 时接口返回 401。
|
||||
def test_missing_token_returns_401(client):
|
||||
test_client, _ = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item()]}, token=None)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["results"] == []
|
||||
assert pool.db.audits
|
||||
|
||||
|
||||
# 缺 data 节点时整包 422,一条都不落。
|
||||
def test_missing_data_returns_422(client):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {})
|
||||
assert resp.status_code == 422
|
||||
assert repo.count() == 0
|
||||
assert pool.db.contracts == {}
|
||||
|
||||
|
||||
# 超过 100 条时整包 422,假库仍空。
|
||||
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)]
|
||||
resp = _post(test_client, {"data": rows})
|
||||
assert resp.status_code == 422
|
||||
assert repo.count() == 0
|
||||
assert pool.db.contracts == {}
|
||||
|
||||
|
||||
# 合法一行 ACCEPTED,process_status=0,fid 原样落库。
|
||||
def test_accepts_valid_row(client):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item()]})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
|
@ -99,7 +107,7 @@ def test_accepts_valid_row(client):
|
|||
"message": "采购合同数据已接收,等待处理",
|
||||
}
|
||||
]
|
||||
stored = repo.get(200001)
|
||||
stored = pool.db.contracts[200001]
|
||||
assert stored["process_status"] == 0
|
||||
assert stored["oms_id"] == 20260928000001
|
||||
assert stored["supplier_id"] == "SUP001"
|
||||
|
|
@ -108,19 +116,21 @@ def test_accepts_valid_row(client):
|
|||
assert stored["contract_no"] is None
|
||||
|
||||
|
||||
# 跨请求同 fid:DUPLICATE,库里品名不改。
|
||||
def test_existing_fid_is_duplicate_and_not_updated(client):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
_post(test_client, {"data": [_item()]})
|
||||
resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]})
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["resultCode"] == "DUPLICATE"
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(
|
||||
test_client,
|
||||
{"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[1]["resultCode"] == "CONFLICT"
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item(contractEntryStatus=0, contractNo=None)]})
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["resultCode"] == "REJECTED"
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(
|
||||
test_client,
|
||||
{"data": [_item(fid=200002, contractEntryStatus=0, contractNo="HT20260928002")]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
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_no"] == "HT20260928002"
|
||||
|
||||
|
||||
# 结束日早于开始日时 REJECTED。
|
||||
def test_end_date_before_start_is_rejected(client):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(
|
||||
test_client,
|
||||
{"data": [_item(contractStartDate="2027-09-30", contractEndDate="2026-10-01")]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item(omsId="20260928000001")]})
|
||||
assert resp.status_code == 200
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item(goodsMdmCode="")]})
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["resultCode"] == "REJECTED"
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
_post(test_client, {"data": [_item(fid=1, omsId=1)]})
|
||||
resp = _post(
|
||||
test_client,
|
||||
|
|
@ -201,18 +217,34 @@ def test_mixed_batch_still_http_200(client):
|
|||
assert resp.status_code == 200
|
||||
codes = [r["resultCode"] for r in resp.json()["results"]]
|
||||
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):
|
||||
test_client, _ = client
|
||||
|
||||
class Boom:
|
||||
def insert(self, record):
|
||||
raise RepoUnavailable("数据库不可用")
|
||||
|
||||
app.dependency_overrides[get_contract_repo] = lambda: Boom()
|
||||
test_client, pool = client
|
||||
pool.db.raise_execute = ora_error(
|
||||
12170,
|
||||
"ORA-12170: TNS:Connect timeout occurred at rpa_purchase_contract_data",
|
||||
)
|
||||
resp = _post(test_client, {"data": [_item()]})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["code"] == 503
|
||||
assert resp.json()["msg"] == "数据库不可用"
|
||||
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
|
||||
|
||||
import os
|
||||
|
|
@ -8,14 +9,14 @@ from fastapi.testclient import TestClient
|
|||
|
||||
os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token"
|
||||
|
||||
from app.inbound.memory_repo import MemorySalesGoodsRepo
|
||||
from app.inbound.repo import RepoUnavailable
|
||||
from app.main import app, get_repo
|
||||
from app.main import app, get_pool
|
||||
from tests.fake_oracle import FakePool, ora_error
|
||||
|
||||
TOKEN = "test-oms-token"
|
||||
PATH = "/api/oms/v1/sales-goods"
|
||||
|
||||
|
||||
# 构造一条合法的接口 1 明细,overrides 覆盖字段。
|
||||
def _item(**overrides):
|
||||
row = {
|
||||
"omsId": 2026092801,
|
||||
|
|
@ -40,61 +41,72 @@ def _item(**overrides):
|
|||
return row
|
||||
|
||||
|
||||
# 注入 FakePool,整次请求走同一块假库。
|
||||
@pytest.fixture
|
||||
def client():
|
||||
repo = MemorySalesGoodsRepo()
|
||||
app.dependency_overrides[get_repo] = lambda: repo
|
||||
pool = FakePool()
|
||||
app.dependency_overrides[get_pool] = lambda: pool
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client, repo
|
||||
yield test_client, pool
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# 带或不带 X-Oms-Token 发 POST。
|
||||
def _post(client, body, token=TOKEN):
|
||||
headers = {"X-Oms-Token": token} if token is not None else {}
|
||||
return client.post(PATH, json=body, headers=headers)
|
||||
|
||||
|
||||
# 缺 X-Oms-Token 时接口返回 401。
|
||||
def test_missing_token_returns_401(client):
|
||||
test_client, _ = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item()]}, token=None)
|
||||
assert resp.status_code == 401
|
||||
body = resp.json()
|
||||
assert body["code"] == 401
|
||||
assert body["msg"] == "身份认证失败"
|
||||
assert body["results"] == []
|
||||
assert pool.db.audits
|
||||
|
||||
|
||||
# 错误 token 时接口返回 401。
|
||||
def test_wrong_token_returns_401(client):
|
||||
test_client, _ = client
|
||||
test_client, _pool = client
|
||||
resp = _post(test_client, {"data": [_item()]}, token="bad")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 401
|
||||
|
||||
|
||||
# 缺 data 节点时整包 422,一条都不落。
|
||||
def test_missing_data_returns_422(client):
|
||||
test_client, _ = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["code"] == 422
|
||||
assert resp.json()["results"] == []
|
||||
assert pool.db.sales == {}
|
||||
|
||||
|
||||
# data 空数组时整包 422。
|
||||
def test_empty_data_returns_422(client):
|
||||
test_client, _ = client
|
||||
test_client, _pool = client
|
||||
resp = _post(test_client, {"data": []})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["code"] == 422
|
||||
|
||||
|
||||
# 超过 100 条时整包 422,假库仍空。
|
||||
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)]
|
||||
resp = _post(test_client, {"data": rows})
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item()]})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
|
@ -106,26 +118,26 @@ def test_accepts_valid_row(client):
|
|||
"message": "接收成功,等待RPA处理",
|
||||
}
|
||||
]
|
||||
stored = repo.get(2026092801)
|
||||
stored = pool.db.sales[2026092801]
|
||||
assert stored["deal_flag"] == 0
|
||||
assert stored["bar_code"] == "06901234567890"
|
||||
assert stored["goods_mdm_code"] == "QX0100167583"
|
||||
|
||||
|
||||
# 跨请求同 omsId:DUPLICATE,库里品名不改。
|
||||
def test_existing_oms_id_is_duplicate_and_not_updated(client):
|
||||
# 跨请求同 omsId:DUPLICATE,库里品名不改。
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
_post(test_client, {"data": [_item()]})
|
||||
resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]})
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
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):
|
||||
# 同一 POST 里两条相同 omsId:后一条 CONFLICT,只落第一条。
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(
|
||||
test_client,
|
||||
{"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[1]["resultCode"] == "CONFLICT"
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item(goodsMdmCode="")]})
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["resultCode"] == "REJECTED"
|
||||
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):
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
_post(test_client, {"data": [_item(omsId=1)]})
|
||||
resp = _post(
|
||||
test_client,
|
||||
|
|
@ -164,27 +178,72 @@ def test_mixed_batch_still_http_200(client):
|
|||
assert resp.status_code == 200
|
||||
codes = [r["resultCode"] for r in resp.json()["results"]]
|
||||
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):
|
||||
# JSON 字符串 "2026092801" 不转整数。
|
||||
test_client, repo = client
|
||||
test_client, pool = client
|
||||
resp = _post(test_client, {"data": [_item(omsId="2026092801")]})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["results"][0]["resultCode"] == "REJECTED"
|
||||
assert repo.count() == 0
|
||||
assert pool.db.sales == {}
|
||||
|
||||
|
||||
def test_repo_unavailable_returns_503(client):
|
||||
test_client, _ = client
|
||||
|
||||
class Boom:
|
||||
def insert(self, record):
|
||||
raise RepoUnavailable("数据库不可用")
|
||||
|
||||
app.dependency_overrides[get_repo] = lambda: Boom()
|
||||
# 库故障时 HTTP 503,body 固定「数据库不可用」,不含 ORA 和表名。
|
||||
def test_repo_unavailable_returns_503_without_ora(client):
|
||||
test_client, pool = client
|
||||
pool.db.raise_execute = ora_error(
|
||||
12170,
|
||||
"ORA-12170: TNS:Connect timeout occurred at rpa_sales_goods_mapping_data",
|
||||
)
|
||||
resp = _post(test_client, {"data": [_item()]})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["code"] == 503
|
||||
assert resp.json()["results"] == []
|
||||
body = resp.json()
|
||||
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