diff --git a/pytest.ini b/pytest.ini index 068d9fd..217887d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -31,6 +31,8 @@ markers = appium: 所有 Appium 测试 playwright: 所有 Playwright 测试 cross_end: 跨端 E2E 测试(管理端 + 司机安卓端联动) + api: API 接口测试 (tests/api/) + yun8_sync: yun8 运输流程 API 造数(E2E + SQL 写入,会变更测试环境数据) # 日志格式 log_cli = true diff --git a/requirements.txt b/requirements.txt index 95044f8..20ce8ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ Appium-Python-Client>=3.0 # 移动端自动化(仅 Web 端测试可不装) pymysql>=1.0 # 数据库操作(测试中更新风控状态等) PyYAML>=6.0 # tests/config/test_config.yaml 配置加载 allure-pytest>=2.13 # Allure 测试报告 +requests>=2.28 # tests/apis/yun8 HTTP 客户端 diff --git a/tests/api/README.md b/tests/api/README.md new file mode 100644 index 0000000..6cc9fa8 --- /dev/null +++ b/tests/api/README.md @@ -0,0 +1,188 @@ +# yun8 运输流程 API 造数(接口自动化) + +将 `yun8.postman_collection.json` 中 **"运输流程"** 全部 18 个接口 + SQL 写入实现为 +pytest 用例 + 造数 CLI,与 `tests/` 框架分层对齐(`apis/` 接口层 ≙ `pages/` PO 层)。 + +> ⚠️ 本套件会**真实调用测试环境接口并写入数据库**(造运单、打款、回单、ETC 发票), +> 使用 `yun8_sync` marker 隔离,不会混入默认冒烟扫描。 + +--- + +## 分层结构 + +``` +tests/ +├── apis/yun8/ # 接口层(≙ pages/ PO 层) +│ ├── client.py # Yun8ApiClient 门面: 司机/管理/滇运通 3 套会话 +│ ├── driver_api.py # step01-05 司机端接口 +│ ├── accounting_api.py # step06-09 运费核算 +│ ├── audit_payment_api.py # step10-16 审核 & 付款 +│ ├── receipt_api.py # step17-20 回单/上报 +│ ├── third_party_api.py # step21 滇运通(独立 host/token) +│ ├── sql_templates.py # 全部 SQL(参数化,%s 占位) +│ ├── pipeline.py # Yun8Pipeline 24 步编排(Postman 变量上下文) +│ └── cli.py # 造数 CLI(python -m tests.apis.yun8.cli) +├── api/ # 用例层 +│ ├── conftest.py # yun8_config / yun8_db / yun8_pipeline fixtures +│ └── test_yun8_transport_flow.py # P0 E2E: 5 个有序阶段用例 +└── config/test_config.yaml # yun8_api 配置段(数据库复用 database 段) +``` + +--- + +## 流程概览 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 阶段 A: 司机端 (Step 01-05) │ +│ 查询货源 → 接单 → 查询待运输单 → 装货 → 卸货 │ +│ 变量: mainOrderCode → code / vehicleCode / orderCode │ +├─────────────────────────────────────────────────────────────────┤ +│ 阶段 B: 运费核算 (Step 06-09) │ +│ 查询运单 → 运单详情 → 核算试算 → 核算提交 │ +│ 变量: 30+ 核算字段 + ticketImageUrls │ +├─────────────────────────────────────────────────────────────────┤ +│ 阶段 C: 审核 & 付款 (Step 10-16) │ +│ 查询审核 → 审核通过 → 队长分账(可选) → 油卡充值 → 司机打款 → 货主打款│ +├─────────────────────────────────────────────────────────────────┤ +│ 阶段 D: 回单文件 + SQL (Step 17-21) │ +│ 运单上传(payNo) → 打款记录 → 文件名前缀 → INSERT → 上报运单 │ +├─────────────────────────────────────────────────────────────────┤ +│ 阶段 E: ETC 开票 SQL (Step 22-24) │ +│ 写入 base_etc_invoice (多条) → 写入 tms_order_etc_push │ +│ → 更新 tms_order_detail 开票状态 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 上下游变量传递 + +| 步骤 | 产出变量 | 消费步骤 | +|------|---------|---------| +| 01 查询货源 | `mainOrderCode`, `residueNumber` | 02, 09 | +| 03 查询待运输单 | `code`, `vehicleCode`, `orderCode` | 04-24 | +| 06 运费核算 | 30+ 字段 (branchCode, oilFee, ...) | 09, 12 | +| 07 运单详情 | `deliveryCashFee`, `driverActualFreight`, ... | 09, 15 | +| 08 核算试算 | `ticketImageUrls` | 09 | +| 17 运单上传 | `payNo` (sendMsg 正则提取) | SQL | +| 18 打款记录 | 所有 `code` | 19 | +| 19 文件名前缀 | `filePrefix` | SQL | + +--- + +## 使用 + +### pytest 用例(5 阶段有序 E2E,带 Allure 报告与断言) + +```bash +# 完整流程(5 个阶段用例,前置失败自动 skip 后续) +pytest tests/api -m yun8_sync -v + +# 生成 Allure 报告 +allure generate output/allure-results -o output/allure-report --clean +``` + +阶段与用例对应: A 司机端 / B 运费核算 / C 审核付款 / D 回单+SQL / E ETC 开票。 +`execute_after_audit=false` 时 D、E 阶段自动 skip。 + +### 造数 CLI(循环造数 / 分阶段运行) + +```bash +# 完整流程 +python -m tests.apis.yun8.cli --auth-driver "Bearer xxx" --auth-admin "Bearer yyy" + +# 仅司机端 (Step 01-05) / 仅核算 (06-09) / 仅审核付款 (10-16) / 仅回单+SQL+上报 (17-24) +python -m tests.apis.yun8.cli --stage driver +python -m tests.apis.yun8.cli --stage accounting +python -m tests.apis.yun8.cli --stage payment +python -m tests.apis.yun8.cli --stage receipt + +# 循环执行 10 次,每次间隔 5 秒 +python -m tests.apis.yun8.cli --repeat 10 --delay 5 + +# 仅 receipt 阶段循环 100 次 +python -m tests.apis.yun8.cli --stage receipt --repeat 100 --delay 2 + +# 预览 SQL 不写库(配合 random 重量模式,每次自动换新重量) +python -m tests.apis.yun8.cli --dry-run +python -m tests.apis.yun8.cli --repeat 5 --dry-run +``` + +> ⚠️ **`--dry-run` 不是只读模式**:仅跳过回单/ETC 的 SQL 写入(打印 mogrify 渲染的 +> SQL),HTTP 步骤仍真实执行,driver 阶段的 reset SQL 也不受 dry-run 影响。 + +> 独立运行 `payment` 或 `receipt` 阶段时,会自动从 admin 端补全前置数据 +> (code/orderCode),无需手动指定。 + +### 配置 + +统一在 `tests/config/test_config.yaml` 的 **`yun8_api:`** 段维护: + +- `tokens`: 司机端/管理端 Bearer Token(CLI `--auth-driver` / `--auth-admin` 可覆盖) +- `driver_params`: 重量模式(`fixed` 固定值 / `random` 每次随机 1.0~10.0)、 + 装卸货经纬度覆盖、待运输单重置、装卸货照片 URL +- `accounting`: 核算固定参数 + `execute_after_audit`(是否执行 12-21 步) +- `payment`: 打款参数;`etc_invoice`: ETC 发票条数 +- `dianyuntong.enabled`: 滇运通审核开关(默认关闭) + +> `branchCode` / `businessTypeCode` / `payeeCode` / `shipmentPhone` / +> `shipmentPartyCode` 优先从 step06 返回值读取,配置仅作兜底。 + +--- + +## 故障处理 + +| 现象 | 处理方式 | +|------|---------| +| WAF 拦截 (SQL注入误判) | 已内置:直连服务器(trust_env=False)+ 手工 query string 编码 | +| "运单正被处理中" | 已内置:自动重试 5 次,间隔递增 5~13s | +| "货运单不存在" | Token 过期或运单状态不对,检查 admin token | +| 业务 code≠0 | **会直接失败**(迁移后行为):CLI 计入失败循环,pytest 用例 RED | +| 提取不到 payNo | **会直接失败**(迁移后行为):不再写空 front_seq_no 的脏行 | +| 独立阶段提示 code 为空 | 已内置:自动从 admin 端补全 | +| 日志 | `output/logs/test_run_*.log`(DEBUG 全量 URL,含 WAF 排查线索) | + +--- + +## 接口对照表 + +(Step 编号与 `tests/apis/yun8` 代码中的 stepNN 方法一一对应) + +| Step | 名称 | 方法 | 路径 | Auth | +|------|------|------|------|------| +| 01 | 查询货源 | GET | `/ntocc-tms-api/order/orderHallList` | Driver | +| 02 | 接单 | POST | `/ntocc-tms-api/orderDetail/receive` | Driver | +| 03 | 查询待运输单 | GET | `/ntocc-tms-api/orderDetail` | Driver | +| 04 | 装货 | PUT | `/ntocc-tms-api/orderDetail/fill` | Driver | +| 05 | 卸货 | PUT | `/ntocc-tms-api/orderDetail/uploadTicketImage` | Driver | +| 06 | 运费核算查询运单 | GET | `/ntocc-tms-api/orderDetail/orderDetailHandlePageList` | Admin | +| 07 | 查询运单详情 | POST | `/ntocc-tms-api/orderDetail/getOrderDetailExpensesParamList` | Admin | +| 08 | 核算试算 | PUT | `/ntocc-tms-api/orderDetail/calculateOrderDetail` | Admin | +| 09 | 核算提交 | PUT | `/ntocc-tms-api/orderDetail/orderHandleEditSubmitHandle` | Admin | +| 10 | 查询运单审核 | GET | `/ntocc-tms-api/orderDetail` (status=6&isSettle=1) | Admin | +| 11 | 核算审核通过 | POST | `/ntocc-tms-api/orderDetail/auditOrderDetail` | Admin | +| 12 | 队长分账预览 | GET | `/ntocc-tms-api/orderDetail/captainSplitAdjustPreview/{code}` | Admin | +| 13 | 队长分账调整 | POST | `/ntocc-tms-api/orderDetail/adjustCaptainSplit` | Admin | +| 14 | 单油卡充值 | POST | `/ntocc-tms-api/finance/singleOil` | Admin | +| 15 | 垫资-财务打款 | POST | `/ntocc-tms-api/arpaTransfer/payToDriver` | Admin | +| 16 | 垫资-货主打款 | POST | `/ntocc-tms-api/orderDetail/applicationForPayment` | Admin | +| 17 | 查询运单上传(提取 payNo) | GET | `/ntocc-basic-api/dytBusiness/orderDetail` (status=5) | Admin | +| 18 | 查询打款记录 | GET | `/ntocc-tms-api/remitDefinite` | Admin | +| 19 | 查询文件名前缀 | GET | `/ntocc-tms-api/centralizedPayment/downLoadElectronicReceipt` | Admin | +| 20 | 上报运单 | POST | `/ntocc-basic-api/dytBusiness/reportOrderDetail` | Admin | +| 21 | 滇运通审核 | POST | `https://yndyt.cxtech.vip/ntocc-tms-api/orderDetail/updateOrderDetailExt` | 滇运通 | +| — | 回单 SQL INSERT | — | `ntocc.base_gd_mft_file` | — | +| 22 | ETC 开票信息写入 | SQL INSERT | `ntocc.base_etc_invoice` | — | +| 23 | ETC 推送记录写入 | SQL INSERT | `ntocc.tms_order_etc_push` | — | +| 24 | 运单 ETC 开票状态更新 | SQL UPDATE | `ntocc.tms_order_detail` | — | + +> 步骤 22 每个运单插入 `etc_invoice.count` 条发票(config.yaml 配置,默认 3),每条的 +> **发票号码**(随机 20 位数字)、**金额**(随机分配,**所有发票合计 ≤ 运费×50%**,运费 +> 优先取 `driverActualFreight`、缺失时取 `shipmentActualFreight`,超限或运费未知则跳过 +> 发票写入)、**进站/出站**(按广东→河北路段链取相邻两站)、**时间**(交易时间在运单窗口 +> 内逐条推进)各不相同;税额、开票方、发票类型、URL 等其余字段用固定值。 +> 步骤 22/23 的 `order_code`/`order_no` 取步骤 03 的 `orderCode`,`push_order_no` 为其后 +> 追加 `-1`,`order_detail_code` 取步骤 03 的 `code`,两个表的 `code` 按现有数据规则 +> (32 位十六进制)自动生成,随 receipt 阶段执行(`--dry-run` 仅预览不写库)。 +> 步骤 23 的推送记录固定写入 `invoice_pull_status=2`、`start_status=1`、`end_status=1`、 +> `start_result='成功'`、`end_result='成功'`;步骤 24 将步骤 03 `orderCode` 对应运单的 +> `etc_invoice_status` 置为 3。 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..6edf36c --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1 @@ +"""API 接口用例层 — 对应 Web 端 tests/web/、App 端 tests/app/ 的分域组织。""" diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..c35b7a0 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,44 @@ +"""tests/api conftest — yun8 运输流程 API 造数 fixtures。 + +Usage: + def test_xxx(yun8_config, yun8_pipeline, yun8_context): + yun8_pipeline.run_driver_flow() +""" + +import pytest + + +@pytest.fixture(scope="session") +def yun8_config(config): + """yun8 运输流程 API 配置段(test_config.yaml 的 yun8_api)。""" + return config["yun8_api"] + + +@pytest.fixture(scope="class") +def yun8_db(yun8_config): + """class 级数据库长连接(造数 SQL 复用)。""" + from tests.core.db_helper import Db + db = Db() + yield db + db.close() + + +@pytest.fixture(scope="class") +def yun8_pipeline(yun8_config, yun8_db): + """class 级 Yun8Pipeline — 5 个阶段用例共享同一份 Postman 变量上下文。 + + 注意: 根 conftest 的 logger fixture 是 function 级, + 这里直接用 get_logger(pipeline 内部亦如此)。 + """ + from tests.apis.yun8.pipeline import Yun8Pipeline + pipe = Yun8Pipeline(yun8_config, yun8_db) + pipe.set_auth(yun8_config["tokens"]["driver_token"], + yun8_config["tokens"]["admin_token"]) + yield pipe + pipe.close() + + +@pytest.fixture(scope="class") +def yun8_context(): + """跨阶段共享上下文:记录中断原因,供后续阶段 skip。""" + return {"aborted": None} diff --git a/tests/api/test_yun8_transport_flow.py b/tests/api/test_yun8_transport_flow.py new file mode 100644 index 0000000..0d1c4f3 --- /dev/null +++ b/tests/api/test_yun8_transport_flow.py @@ -0,0 +1,114 @@ +"""yun8 运输流程 API 造数 — 24 步全链路 E2E。 + +⚠️ 此套件会真实调用测试环境接口并写入数据库(造运单/打款/回单/ETC 发票), + 不属于默认冒烟扫描范围,请显式按 marker 运行: + pytest tests/api -m yun8_sync -v + +5 个阶段用例按定义顺序执行(同一类内定义序即执行序,勿并行), +共享一套 Yun8Pipeline(Postman Collection Variables 上下文): + 阶段 A driver → 阶段 B accounting → 阶段 C audit_payment + → 阶段 D receipt → 阶段 E etc +前置阶段失败时,后续阶段自动 skip(与 CLI 一次循环失败即中止语义一致)。 + +Usage: + pytest tests/api/test_yun8_transport_flow.py -v +""" + +import allure +import pytest + +pytestmark = [pytest.mark.api, pytest.mark.yun8_sync, pytest.mark.p0] + + +@allure.epic("运八网络货运平台") +@allure.feature("yun8 运输流程 API 造数 — 24 步全链路") +class TestYun8TransportFlow: + """运输流程全链路:接口驱动 + SQL 造数(详见 tests/api/README.md)。""" + + # ── 中断守卫:前置阶段失败则跳过 ────────────────────────────── + def _guard(self, yun8_context: dict): + if yun8_context["aborted"]: + pytest.skip(f"前置阶段失败: {yun8_context['aborted']}") + + def _run(self, yun8_context: dict, stage: str, fn, *args, **kwargs): + """执行编排方法,失败时记录中断原因后原样抛出。""" + try: + return fn(*args, **kwargs) + except Exception as exc: + yun8_context["aborted"] = f"{stage}: {exc}" + raise + + @staticmethod + def _execute_after_audit(yun8_pipeline) -> bool: + return yun8_pipeline.config.get("accounting", {}).get( + "execute_after_audit", True) + + # ═══════════════════════════════════════════════════════════════ + # 阶段 A-E + # ═══════════════════════════════════════════════════════════════ + + @allure.story("阶段A — 司机端 (Step 01-05)") + @allure.title("TC-Y8-001 司机端 查询货源→接单→装货→卸货") + def test_stage_a_driver_flow(self, yun8_pipeline, yun8_context): + """查询货源→接单→查询待运输单→装货→卸货,产出 code/orderCode。""" + self._guard(yun8_context) + with allure.step("执行司机端流程 run_driver_flow (Step 01-05)"): + vars_ = self._run(yun8_context, "阶段A", + yun8_pipeline.run_driver_flow) + assert vars_.get("mainOrderCode"), "mainOrderCode 为空" + assert vars_.get("code"), "待运输单 code 为空" + assert vars_.get("orderCode"), "orderCode 为空" + + @allure.story("阶段B — 运费核算 (Step 06-09)") + @allure.title("TC-Y8-002 核算查询→详情→试算→提交") + def test_stage_b_accounting_flow(self, yun8_pipeline, yun8_context): + """查询运单→运单详情→核算试算→核算提交,产出 30+ 核算字段。""" + self._guard(yun8_context) + with allure.step("执行运费核算流程 run_accounting_flow (Step 06-09)"): + vars_ = self._run(yun8_context, "阶段B", + yun8_pipeline.run_accounting_flow) + assert vars_.get("deliveryCashFee") != "", "deliveryCashFee 为空" + assert vars_.get("shipmentActualCash") != "", "shipmentActualCash 为空" + + @allure.story("阶段C — 审核 & 付款 (Step 10-16)") + @allure.title("TC-Y8-003 审核通过→油卡充值→司机打款→货主打款") + def test_stage_c_audit_payment_flow(self, yun8_pipeline, yun8_context): + """查询审核→审核通过→(分账)→油卡充值→财务打款→货主打款。 + + 成功判定 = 编排全程无 Yun8StepError / HTTP 异常。 + execute_after_audit=false 时编排内部自行跳过 12-16 步。 + """ + self._guard(yun8_context) + with allure.step("执行审核付款流程 run_audit_payment_flow (Step 10-16)"): + self._run(yun8_context, "阶段C", + yun8_pipeline.run_audit_payment_flow) + + @allure.story("阶段D — 回单文件 + SQL (Step 17-21)") + @allure.title("TC-Y8-004 提取payNo→回单INSERT→上报运单") + def test_stage_d_receipt_flow(self, yun8_pipeline, yun8_context): + """payNo 提取→打款记录分页→文件名前缀→回单 INSERT→上报运单。""" + self._guard(yun8_context) + if not self._execute_after_audit(yun8_pipeline): + pytest.skip("execute_after_audit=false,receipt 阶段被配置跳过") + with allure.step("执行回单文件流程 run_receipt_file_flow (Step 17-21)"): + results = self._run(yun8_context, "阶段D", + yun8_pipeline.run_receipt_file_flow, + dry_run=False) + assert yun8_pipeline.vars.get("payNo"), "payNo 为空" + assert results, "无任何打款记录可处理(remit_records 为空)" + inserted = [r for r in results if r.get("status") == "inserted"] + assert inserted, f"没有成功插入的回单记录: {results}" + + @allure.story("阶段E — ETC 开票 SQL (Step 22-24)") + @allure.title("TC-Y8-005 ETC发票INSERT→推送记录INSERT→开票状态更新") + def test_stage_e_etc_invoice_flow(self, yun8_pipeline, yun8_context): + """写入 base_etc_invoice × N → tms_order_etc_push → 更新开票状态。""" + self._guard(yun8_context) + if not self._execute_after_audit(yun8_pipeline): + pytest.skip("execute_after_audit=false,ETC 阶段被配置跳过") + with allure.step("执行 ETC 开票流程 run_etc_invoice_flow (Step 22-24)"): + results = self._run(yun8_context, "阶段E", + yun8_pipeline.run_etc_invoice_flow, + dry_run=False) + errors = [r for r in results if r.get("status") == "error"] + assert not errors, f"ETC SQL 写入存在失败项: {errors}" diff --git a/tests/apis/__init__.py b/tests/apis/__init__.py new file mode 100644 index 0000000..a2441c4 --- /dev/null +++ b/tests/apis/__init__.py @@ -0,0 +1 @@ +"""API 客户端层 — 与 pages/(PO 层)对称,按业务域封装接口调用。""" diff --git a/tests/apis/yun8/__init__.py b/tests/apis/yun8/__init__.py new file mode 100644 index 0000000..19e034c --- /dev/null +++ b/tests/apis/yun8/__init__.py @@ -0,0 +1,13 @@ +"""yun8 运输流程 API 客户端包。 + +分层: + client.py Yun8ApiClient 门面(3 套会话组装 + 鉴权) + driver_api.py 司机端 step01-05 + accounting_api.py 运费核算 step06-09 + audit_payment_api.py 审核 & 付款 step10-16 + receipt_api.py 回单/上报 step17-20 + third_party_api.py 滇运通 step21 + sql_templates.py 全部 SQL 语句与参数化构造器 + pipeline.py Yun8Pipeline 24 步编排 + cli.py 造数 CLI 入口 +""" diff --git a/tests/apis/yun8/accounting_api.py b/tests/apis/yun8/accounting_api.py new file mode 100644 index 0000000..7e74e29 --- /dev/null +++ b/tests/apis/yun8/accounting_api.py @@ -0,0 +1,337 @@ +"""运费核算接口 Mixin — step06-09(管理端,含 ~190 字段核算提交报文)。 + +所有方法签名/返回值/默认值与旧 yun8_sync/client.py 逐字一致, +仅将原始 requests.Session 调用替换为 BaseHttpClient(self.admin_http)。 + +Usage: + from tests.apis.yun8.accounting_api import AccountingApi + class Yun8ApiClient(AccountingApi, ...): ... +""" + + +class AccountingApi: + """运费核算接口(挂载于 Yun8ApiClient,依赖 admin_http / ac / dp 属性)。""" + + # ================================================================== + # Step 06: 运费核算查询运单 + # ================================================================== + def step06_query_for_accounting(self, order_code: str, + page_size: int = 20, page_num: int = 1) -> dict: + """ + Postman Test Script: 设置 30+ 个 collection variables + 从 data.records[0] 中提取所有字段 + + Returns: dict with all fields from records[0] + """ + params = { + "status": "3", + "lastLoadingTimeRange": "", + "fillTimeRange": "", + "lastUnloadingTimeRange": "", + "signTimeRange": "", + "gmtModifiedRange": "", + "rangeValue_start_1": "", + "rangeValue_end_1": "", + "rangeValue_start_2": "", + "rangeValue_end_2": "", + "rangeValue_start_3": "", + "rangeValue_end_3": "", + "pageNum": page_num, + "pageSize": page_size, + "gmtCreatedStart": "", + "gmtCreatedEnd": "", + "lastUnloadingTimeStart": "", + "lastUnloadingTimeEnd": "", + "lastLoadingTimeStart": "", + "lastLoadingTimeEnd": "", + "gmtModifiedStart": "", + "gmtModifiedEnd": "", + "fillTimeStart": "", + "fillTimeEnd": "", + "signTimeStart": "", + "signTimeEnd": "", + "ordCreatedByName": "", + "isUnloadingConfirm": "1", + "isCannotAccounting": "0", + "riskControlStatus": "", + "riskControlReason": "", + "sortField": "", + "sortType": "", + "orderCode": order_code, + } + data = self.admin_http.get("/ntocc-tms-api/orderDetail/orderDetailHandlePageList", params) + records = data.get("data", {}).get("records", []) + if not records: + raise RuntimeError("运费核算查询运单: 无记录返回") + return records[0] + + # ================================================================== + # Step 07: 查询运单详情 + # ================================================================== + def step07_query_order_expenses(self, code: str) -> dict: + """ + Postman Test Script: + pm.collectionVariables.set("canWastage", ...); + pm.collectionVariables.set("deliveryCashFee", ...); + pm.collectionVariables.set("deliveryFeeDeserved", ...); + pm.collectionVariables.set("deliveryFeePractical", ...); + pm.collectionVariables.set("driverActualCash", ...); + pm.collectionVariables.set("driverActualFreight", ...); + + Returns: {canWastage, deliveryCashFee, deliveryFeeDeserved, + deliveryFeePractical, driverActualCash, driverActualFreight} + """ + params = {"codes": code} + data = self.admin_http.post( + "/ntocc-tms-api/orderDetail/getOrderDetailExpensesParamList", + params=params) + items = data.get("data", []) + if not items: + raise RuntimeError("查询运单详情: 无数据返回") + r = items[0] + return { + "canWastage": str(r.get("canWastage", "")), + "deliveryCashFee": str(r.get("deliveryCashFee", "")), + "deliveryFeeDeserved": str(r.get("deliveryFeeDeserved", "")), + "deliveryFeePractical": str(r.get("deliveryFeePractical", "")), + "driverActualCash": str(r.get("driverActualCash", "")), + "driverActualFreight": str(r.get("driverActualFreight", "")), + } + + # ================================================================== + # Step 08: 核算试算 (calculateOrderDetail) + # ================================================================== + def step08_calculate_order_detail(self, code: str) -> dict: + """ + Postman Test Script: + pm.collectionVariables.set("ticketImageUrls[]", jsonData.data.ticketImageUrls); + + Returns: dict with ticketImageUrls + 其他 step08 返回的核算数据 (taxPrice 等) + """ + params = {"code": code, "isFirst": "1"} + data = self.admin_http.put("/ntocc-tms-api/orderDetail/calculateOrderDetail", params=params) + inner = data.get("data", {}) or {} + ticket_urls = inner.get("ticketImageUrls", "") + if isinstance(ticket_urls, list): + ticket_urls = ticket_urls[0] if ticket_urls else "" + # 返回所有有用字段,供 pipeline 存入 vars + result = {"ticketImageUrls": str(ticket_urls)} + for key in ("taxPrice", "taxFee", "taxPayFee", "taxPoint", + "price", "realPrice", "shipmentActualCash", + "shipmentActualFreight"): + if key in inner: + result[key] = str(inner[key]) + return result + + # ================================================================== + # Step 09: 核算提交 + # ================================================================== + def step09_submit_accounting(self, accounting_vars: dict) -> dict: + """ + 提交核算,参数来自前面步骤累积的所有变量。 + accounting_vars 应包含 steps 03/06/07/08 的全部字段。 + 优先从 step06 返回值取值,取不到再用 config.yaml 默认值。 + """ + av = accounting_vars + # 便捷取值:vars 优先 → config 兜底 + def _v(key, cfg_key, default=""): + return av.get(key) or self.ac.get(cfg_key, default) + + # Build the massive query string + params = { + "abnormalFee": "0", + "accountCheckAuthStatus": "0", + "accountCheckInfoAuthStatus": "0", + "accountCheckUpAuthStatus": "0", + "accountStatus": "0", + "allTrans": "0", + "allotAmount": "0", + "allotOil": "0", + "allotTaxAmount": "0", + "appSendLoadLocation": "0", + "appSendUnloadLocation": "0", + "applyStatus": "0", + "branchCode": _v("branchCode", "branch_code"), + "businessTypeCode": _v("businessTypeCode", "business_type_code"), + "canWastage": av.get("canWastage", ""), + "cancelTrans": "0", + "cancelType": "0", + "captainSplitCurrentVersion": "0", + "captainSplitMode": self.ac.get("captain_split_mode") or av.get("captainSplitMode", ""), + "cashDeposit": "0", + "coalName": "", + "coalPrice": av.get("coalPrice", ""), + "coalType": av.get("coalType", ""), + "coalTypeName": av.get("coalTypeName", ""), + "coalUnit": "0", + "coalUnitName": av.get("coalUnitName", ""), + "code": av.get("code", ""), + "contractStatus": "1", + "createdBy": av.get("createdBy", ""), + "createdByName": av.get("createdByName", ""), + "deleted": "0", + "deliveryCashFee": av.get("deliveryCashFee", ""), + "deliveryFeeDeserved": av.get("deliveryFeeDeserved", ""), + "deliveryFeePractical": av.get("deliveryFeePractical", ""), + "deliveryOilFee": "0", + "distance": av.get("distance", ""), + "driverActualCash": av.get("driverActualCash", ""), + "driverActualFreight": av.get("driverActualFreight", ""), + "driverCode": av.get("driverCode", ""), + "driverName": av.get("driverName", ""), + "driverPhone": av.get("driverPhone", ""), + "energyCardType": av.get("energyCardType", ""), + "etcType": "0", + "extraFee": "0", + "fillTime": av.get("fillTime", ""), + "finishedTrans": "0", + "gmtCreated": av.get("gmtCreated", ""), + "gmtModified": av.get("gmtModified", ""), + "id": av.get("id", ""), + "inFee": "0", + "infoFee": "0", + "insuranceAmount": "0", + "insuranceFee": "0", + "invoiceIsAll": "0", + "invoiceStatus": "0", + "invoiceStatusCaptain": "0", + "invoiceStatusDriver": "0", + "isAbnormal": "0", + "isCancel": "0", + "isCaptainOrder": "0", + "isCash": "0", + "isCuikuan": "0", + "isDriverComment": "0", + "isEqualDriver": "1", + "isEqualTeam": "1", + "isEqualVehicle": "1", + "isEtcHistory": "0", + "isFill": "1", + "isImport": "0", + "isInsure": "0", + "isLoadingConfirm": "1", + "isLoadingConfirmDate": av.get("isLoadingConfirmDate", ""), + "isOriginalOrder": "1", + "isPaperReturns": "0", + "isReceiptReturn": "0", + "isReportOrder": "0", + "isSend": "0", + "isSendDishonestyVehicle": "0", + "isSendFund": "0", + "isSendFundZhongLian": "0", + "isSendLoadLocation": "0", + "isSendTax": "0", + "isSendUnloadLocation": "0", + "isSendZhongLian": "0", + "isSettle": "0", + "isShipmentComment": "0", + "isSign": "1", + "isSplitOrder": "0", + "isSupplementary": "0", + "isTransfer": "0", + "isTrunkName": av.get("isTrunkName", ""), + "isUnloadingComfirmDate": av.get("isUnloadingComfirmDate", ""), + "isUnloadingConfirm": "1", + "isWsShare": "0", + "loadImgUrls[]": av.get("ticketImageUrls", ""), + "loadLatitude": av.get("loadLatitude", ""), + "loadLongitude": av.get("loadLongitude", ""), + "loadPlace": av.get("loadPlace", ""), + "loadQuantity": av.get("loadQuantity", ""), + "loadWeight": av.get("loadWeight", "1"), + "mainOrderCode": av.get("mainOrderCode", ""), + "modifiedBy": av.get("modifiedBy", ""), + "modifiedByName": av.get("modifiedByName", ""), + "monthlySettlementStatus": "0", + "oilFee": av.get("oilFee", ""), + "onTrans": "0", + "orderCode": av.get("orderCode", ""), + "payAmount": "0", + "payOil": "0", + "payStatus": "0", + "payStep": "10", + "payTaxAmount": "0", + "payTotalAmount": av.get("payTotalAmount", ""), + "payType": "0", + "payeeCode": _v("createdBy", "payee_code"), # step06 createdBy = payeeCode + "payeeName": av.get("payeeName", ""), + "payerReceiver": self.ac.get("payer_receiver", ""), + "payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""), + "platformPrepayment": "0", + "prepaidStatus": "0", + "price": av.get("price", ""), + "proofAmount": "0", + "proofStatus": "0", + "realPrice": av.get("realPrice", ""), + "receiptNum": "", + "receiptPayAmount": "0", + "refuelProportion": "0", + "reportMark": "0", + "reportStage": "0", + "settlementCash": "0", + "settlementOil": "0", + "settlementType": self.ac.get("settlement_type", "2"), + "settlementWeight": av.get("settlementWeight", "1"), + "shipmentActualCash": av.get("shipmentActualCash", ""), + "shipmentActualFreight": av.get("shipmentActualFreight", ""), + "shipmentCardScale": "10", + "shipmentDeliveryFeePractical": av.get("shipmentDeliveryFeePractical", ""), + "shipmentName": av.get("shipmentName", ""), + "shipmentPayStatus": "0", + "shipmentPhone": _v("shipmentPhone", "shipment_phone"), + "shipperDeliveryFee": av.get("shipperDeliveryFee", ""), + "shippingNoteNumber": av.get("shippingNoteNumber", ""), + "signTime": av.get("signTime", ""), + "status": "3", + "takeLatitude": av.get("takeLatitude", ""), + "takeLongitude": av.get("takeLongitude", ""), + "takeOrderType": "2", + "takePlace": av.get("takePlace", ""), + "taxFee": av.get("taxFee", ""), + "taxPayFee": av.get("taxPayFee", ""), + "taxPrice": av.get("taxPrice", ""), + "ticketImageUrls[]": av.get("ticketImageUrls", ""), + "txStatus": "0", + "unloadLatitude": self.dp.get("latitude", ""), + "unloadLongitude": self.dp.get("longitude", ""), + "unloadPlace": av.get("unloadPlace", ""), + "unloadQuantity": av.get("unloadQuantity", ""), + "unloadWeight": av.get("unloadWeight", "1"), + "vehicleCode": av.get("vehicleCode", ""), + "vehicleLicense": av.get("vehicleLicense", ""), + "waitCar": "0", + "waitCheckTrans": "0", + "waitDriverCheckTrans": "0", + "waitPay": "0", + "waitSettle": "0", + "waitTrans": "0", + "wastage": "0", + "weight": av.get("weight", "1"), + "whetherOverload": "0", + "wsShareRatio": "0", + "deductionFee": "0", + "driverErase": "0", + "driverFixedPay": "0", + "driverSettlementWeight": av.get("driverSettlementWeight", "1"), + "electricityCardScale": "10", + "gasCardScale": "10", + "isTrunk": "0", + "limitWastageAmount": "500", + "limitWastageType": "1", + "shipmentErase": "0", + "shipmentPartyCode": _v("shipmentPartyCode", "shipment_party_code"), + "shipmentSettlementWeight": av.get("shipmentSettlementWeight", "1"), + "shipperFixedPay": "0", + "taxPoint": av.get("taxPoint", ""), + "taxPriceFixedPay": "0", + "orderExtraFeeListStr": "[]", + "BranchCode": _v("branchCode", "branch_code"), + "unloadImgaes": av.get("ticketImageUrls", ""), + "truckLoadingImgaes": av.get("ticketImageUrls", ""), + "IsTaxPoint": "0", + "RealPrice": av.get("realPrice", ""), + "gmtCreated2": av.get("gmtCreated", ""), + "gmtModified2": av.get("gmtModified", ""), + } + return self.admin_http.request_with_encoded_query( + "PUT", "/ntocc-tms-api/orderDetail/orderHandleEditSubmitHandle", params=params) diff --git a/tests/apis/yun8/audit_payment_api.py b/tests/apis/yun8/audit_payment_api.py new file mode 100644 index 0000000..a24510c --- /dev/null +++ b/tests/apis/yun8/audit_payment_api.py @@ -0,0 +1,173 @@ +"""审核 & 付款接口 Mixin — step10-16(管理端)。 + +所有方法签名/返回值/默认值与旧 yun8_sync/client.py 逐字一致, +仅将原始 requests.Session 调用替换为 BaseHttpClient(self.admin_http)。 + +Usage: + from tests.apis.yun8.audit_payment_api import AuditPaymentApi + class Yun8ApiClient(AuditPaymentApi, ...): ... +""" + +import math + + +class AuditPaymentApi: + """审核 & 付款接口(挂载于 Yun8ApiClient,依赖 admin_http / ac / pm 属性)。""" + + # ================================================================== + # Step 10: 查询运单审核 + # ================================================================== + def step10_query_audit(self, order_code: str, + page_size: int = 20, page_num: int = 1) -> dict: + """ + GET /ntocc-tms-api/orderDetail (status=6 已结算, isSettle=1) + Admin 端查询已结算运单用于审核。 + """ + params = { + "isAbnormalHelp": "", + "gmtCreatedRange": "", + "lastLoadingTimeRange": "", + "fillTimeRange": "", + "lastUnloadingTimeRange": "", + "signTimeRange": "", + "settleTimeRange": "", + "gmtModifiedRange": "", + "pageNum": page_num, + "pageSize": page_size, + "isSettle": "1", + "status": "6", + "payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""), + "sortField": "", + "sortType": "", + "orderCode": order_code, + } + return self.admin_http.get("/ntocc-tms-api/orderDetail", params) + + # ================================================================== + # Step 11: 核算审核通过 + # ================================================================== + def step11_approve_audit(self, code: str) -> dict: + """ + POST /ntocc-tms-api/orderDetail/auditOrderDetail?orderDetailCodes={code}&isPass=true + """ + params = {"orderDetailCodes": code, "isPass": "true"} + return self.admin_http.request_with_encoded_query( + "POST", "/ntocc-tms-api/orderDetail/auditOrderDetail", params=params) + + # ================================================================== + # Step 12: 队长分账预览 (captainSplitAdjustPreview) + # ================================================================== + def step12_captain_split_preview(self, order_detail_code: str) -> dict: + """ + GET /ntocc-tms-api/orderDetail/captainSplitAdjustPreview/{code} + 仅在 payerReceiver 不为空且 captainSplitMode=2 时调用。 + """ + path = f"/ntocc-tms-api/orderDetail/captainSplitAdjustPreview/{order_detail_code}" + return self.admin_http.get(path) + + # Step 13: 队长分账调整 (adjustCaptainSplit) + # ================================================================== + def step13_adjust_captain_split(self, order_detail_code: str, + latest_driver_amount: float, + delivery_oil_fee: float) -> dict: + """ + POST /ntocc-tms-api/orderDetail/adjustCaptainSplit + Content-Type: application/json;charset=UTF-8 + + driverAmount = ceil(latestDriverAmount * 0.8 * 100) / 100 + captainAmount = latestDriverAmount - driverAmount + oilFee = deliveryOilFee + """ + driver_amount = math.ceil(latest_driver_amount * 0.8 * 100) / 100.0 + captain_amount = round(latest_driver_amount - driver_amount, 2) + body = { + "orderDetailCode": order_detail_code, + "driverAmount": str(driver_amount), + "captainAmount": str(captain_amount), + "oilFee": delivery_oil_fee, + } + return self.admin_http.post_json( + "/ntocc-tms-api/orderDetail/adjustCaptainSplit", body) + + # Step 14: 单油卡充值 (singleOil) + # ================================================================== + def step14_single_oil(self, order_detail_code: str, amount: str = "0.03", + pay_type: str = "1", category: str = "1") -> dict: + """ + POST /ntocc-tms-api/finance/singleOil + Query params: payType, amount, orderDetailCode, category, isAdvancePayment + No body (content-length: 0) + + 在垫资-财务打款之前调用,处理油卡充值。 + """ + params = { + "payType": pay_type, + "amount": amount, + "orderDetailCode": order_detail_code, + "category": category, + "isAdvancePayment": "1", + } + return self.admin_http.request_with_encoded_query( + "POST", "/ntocc-tms-api/finance/singleOil", params=params) + + # ================================================================== + # Step 15: 垫资-财务打款 + # ================================================================== + def step15_pay_to_driver(self, code: str, amount: str = None, + delivery_cash_fee: str = None, + driver_actual_freight: str = None, + plan_driver_payout: str = "0", + plan_captain_payout: str = "0") -> dict: + """ + POST /ntocc-tms-api/arpaTransfer/payToDriver + amount / deliveryCashFee / driverActualFreight 由 pipeline 从 step07 传入 + planDriverPayout / planCaptainPayout 来自队长分账步骤,无分账时为 "0" + """ + if amount is None: + amount = "0.27" + if delivery_cash_fee is None: + delivery_cash_fee = "0.27" + if driver_actual_freight is None: + driver_actual_freight = "0.3" + params = { + "payTypes": self.pm.get("pay_types", "3,4,5,9,10,11"), + "amount": amount, + "orderDetailCode": code, + "infoFee": "0", + "pageNum": "1", + "pageSize": "20", + "totaltotal": "0", + "payerRole": "0", + "isAdvancePayment": "1", + "planDriverPayout": plan_driver_payout, + "planCaptainPayout": plan_captain_payout, + "allotAmount": "0", + "deliveryCashFee": delivery_cash_fee, + "driverActualFreight": driver_actual_freight, + "total": "0", + "payPlatform": self.pm.get("pay_platform", "3"), + "smsCode": self.pm.get("sms_code", "1234"), + "phone": self.pm.get("phone", "13888888888"), + } + return self.admin_http.request_with_encoded_query( + "POST", "/ntocc-tms-api/arpaTransfer/payToDriver", params=params) + + # ================================================================== + # Step 16: 垫资-货主打款 (申请付款) + # ================================================================== + def step16_apply_for_payment(self, code: str, total_amount: str = None) -> dict: + """ + POST /ntocc-tms-api/orderDetail/applicationForPayment + totalAmount 应从 pipeline 传入 (step06 的 shipmentActualCash) + """ + if total_amount is None: + total_amount = "0.32" + params = { + "payType": self.pm.get("pay_type", "arpa_2"), + "totalAmount": total_amount, + "codes": code, + "payPlatform": self.pm.get("pay_platform", "3"), + "isAdvancePayment": "1", + } + return self.admin_http.request_with_encoded_query( + "POST", "/ntocc-tms-api/orderDetail/applicationForPayment", params=params) diff --git a/tests/apis/yun8/cli.py b/tests/apis/yun8/cli.py new file mode 100644 index 0000000..e11a779 --- /dev/null +++ b/tests/apis/yun8/cli.py @@ -0,0 +1,204 @@ +"""yun8 运输流程造数 CLI。 + +旧 yun8_sync/main.py 的等价入口,复用 tests/apis/yun8 的 API 层与编排层: + python -m tests.apis.yun8.cli --stage all --repeat 10 --delay 5 + +注意: + - --dry-run 仅跳过回单/ETC 的 SQL 写入;HTTP 步骤全部真实执行, + driver 阶段的 reset SQL 也不受 dry-run 影响(与旧工具行为一致)。 + - 数据库连接读取 tests/config/test_config.yaml 的 database 段。 + +阶段选择: + all 完整流程 (默认): 司机端 → 核算 → 付款 → 回单SQL → 上报 + driver 仅司机端 (步骤 1-5) + accounting 仅运费核算 (步骤 6-9) + payment 仅审核付款 (步骤 10-16) + receipt 仅回单文件+SQL+上报 (步骤 14-17) +""" + +import argparse +import sys +import time +import traceback +from datetime import datetime +from pathlib import Path + +import yaml + +from tests.core.config_loader import load_config +from tests.core.db_helper import Db + +from tests.apis.yun8.pipeline import Yun8Pipeline + +STAGE_HELP = """阶段选择: + all 完整流程 (默认): 司机端 → 核算 → 付款 → 回单SQL → 上报 + driver 仅司机端 (步骤 1-5) + accounting 仅运费核算 (步骤 6-9) + payment 仅审核付款 (步骤 10-16) + receipt 仅回单文件+SQL+上报 (步骤 14-17) +""" + + +def _load_yun8_section(path: str | Path | None) -> dict: + """加载 yun8_api 配置段。 + + - path 为空: 读 tests/config/test_config.yaml 的 yun8_api 段 + - path 指向的 YAML 含 yun8_api 键: 直接取该段 + - 否则按旧 yun8_sync/config.yaml 结构兼容解析(database 段忽略) + """ + if path is None: + return load_config()["yun8_api"] + + with open(path, "r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) + + if "yun8_api" in raw: + return raw["yun8_api"] + + # 旧版结构兼容: api/auth/driver_params/... 平铺 + api = raw.get("api", {}) + section = { + "base_url": api.get("base_url", ""), + "verify_ssl": api.get("verify_ssl", False), + "timeout_seconds": api.get("timeout_seconds", 60), + "common_headers": api.get("common_headers", {}), + "driver_headers": api.get("driver", {}), + "admin_headers": api.get("admin", {}), + "tokens": dict(raw.get("auth", {})), + } + for key in ("driver_params", "accounting", "payment", "etc_invoice", + "sql", "dianyuntong"): + if key in raw: + section[key] = raw[key] + return section + + +def run_one_cycle(section: dict, stage: str, driver_token: str, + admin_token: str, dry_run: bool) -> bool: + """执行一次完整流水线,返回 True 表示成功(异常直接向上抛)。""" + db = Db() + pipe = Yun8Pipeline(section, db) + try: + pipe.set_auth(driver_token, admin_token) + + if stage in ("all", "driver"): + pipe.run_driver_flow() + + # reset_order_detail 模式:仅跑司机端,跳过后续全部步骤 + dp = section.get("driver_params", {}) + if dp.get("reset_order_detail", False): + print(f"\n [stop] reset_order_detail=true,跳过步骤 06-21") + return True + + if stage in ("all", "accounting"): + pipe.run_accounting_flow() + + if stage in ("all", "payment"): + pipe.run_audit_payment_flow() + + ac = section.get("accounting", {}) + if ac.get("execute_after_audit", True): + if stage in ("all", "receipt"): + pipe.run_receipt_file_flow(dry_run=dry_run) + # ETC 开票 SQL 写入 (步骤 22-23) + pipe.run_etc_invoice_flow(dry_run=dry_run) + else: + if stage in ("all", "receipt"): + print(f"\n [stop] execute_after_audit=false,跳过 receipt 阶段") + + return True + finally: + pipe.close() + db.close() + + +def main(): + parser = argparse.ArgumentParser( + description="yun8 运输流程完整自动化", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=STAGE_HELP, + ) + parser.add_argument("--config", default=None, + help="配置文件路径 (默认: tests/config/test_config.yaml 的 yun8_api 段)") + parser.add_argument("--auth-driver", default="", + help="司机端 Bearer Token (含 'Bearer ' 前缀)") + parser.add_argument("--auth-admin", default="", + help="管理端 Bearer Token (含 'Bearer ' 前缀)") + parser.add_argument("--stage", default="all", + choices=["all", "driver", "accounting", "payment", "receipt"], + help="运行阶段 (默认: all)") + parser.add_argument("--dry-run", action="store_true", + help="仅预览 SQL,不实际写入数据库 (注意: HTTP 步骤仍真实执行)") + parser.add_argument("--repeat", type=int, default=1, + help="循环执行次数 (默认: 1)") + parser.add_argument("--delay", type=int, default=0, + help="每次循环之间的等待秒数 (默认: 0)") + args = parser.parse_args() + + # 加载配置 + section = _load_yun8_section(args.config) + + # 读取 Token(命令行优先) + tokens = section.get("tokens", {}) + driver_token = args.auth_driver or tokens.get("driver_token", "") + admin_token = args.auth_admin or tokens.get("admin_token", "") + + print("=" * 60) + print(f" yun8 运输流程自动化") + print(f" config: {args.config or 'tests/config/test_config.yaml (yun8_api)'}") + print(f" stage: {args.stage}") + print(f" dry-run: {args.dry_run}") + print(f" repeat: {args.repeat}") + print(f" delay: {args.delay}s") + print(f" 开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print("=" * 60) + + ok_count = 0 + fail_count = 0 + cycle_start = time.time() + + for i in range(1, args.repeat + 1): + if args.repeat > 1: + print(f"\n{'#' * 60}") + print(f"# 第 {i}/{args.repeat} 次循环") + print(f"# 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"{'#' * 60}") + + try: + run_one_cycle(section, args.stage, driver_token, + admin_token, args.dry_run) + ok_count += 1 + if args.repeat > 1: + print(f"\n--- 第 {i}/{args.repeat} 次循环: 成功 ---") + + except KeyboardInterrupt: + print(f"\n 用户中断,已执行 {i-1} 次") + fail_count += (args.repeat - i + 1) + break + except Exception as exc: + fail_count += 1 + print(f"\n--- 第 {i}/{args.repeat} 次循环: 失败 ---") + print(f" 错误: {exc}") + traceback.print_exc() + # 失败后继续下一次(不中断循环) + + # 循环间隔 + if i < args.repeat and args.delay > 0: + print(f" 等待 {args.delay}s 后进入下一次循环...") + time.sleep(args.delay) + + # 汇总 + elapsed = time.time() - cycle_start + print(f"\n{'=' * 60}") + print(f" 全部循环完成") + print(f" 成功: {ok_count} 失败: {fail_count} 总计: {args.repeat}") + print(f" 耗时: {elapsed:.0f}s") + print(f" 结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"{'=' * 60}") + + if fail_count > 0: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/apis/yun8/client.py b/tests/apis/yun8/client.py new file mode 100644 index 0000000..2540c34 --- /dev/null +++ b/tests/apis/yun8/client.py @@ -0,0 +1,95 @@ +"""yun8 API 客户端门面。 + +组装司机端 / 管理端 / 第三方(滇运通)三套 BaseHttpClient 会话, +并挂载各业务域 Mixin 的 step01-21 方法。 + +请求头组装顺序与旧 yun8_sync/client.py._init_session 逐字一致: + 1. 角色专属请求头(跳过空值) + 2. common_headers 中的 host / accept-language / accept-encoding 无条件覆盖 + +Usage: + from tests.apis.yun8.client import Yun8ApiClient + + client = Yun8ApiClient(config["yun8_api"]) + client.set_driver_auth("Bearer xxx") + client.set_admin_auth("Bearer yyy") + data = client.step01_query_goods() +""" + +from tests.core.http_client import BaseHttpClient + +from tests.apis.yun8.driver_api import DriverApi +from tests.apis.yun8.accounting_api import AccountingApi +from tests.apis.yun8.audit_payment_api import AuditPaymentApi +from tests.apis.yun8.receipt_api import ReceiptApi +from tests.apis.yun8.third_party_api import ThirdPartyApi + + +class Yun8ApiClient(DriverApi, AccountingApi, AuditPaymentApi, ReceiptApi, + ThirdPartyApi): + """封装 yun8 全部 API 请求(21 个 step 方法 + 3 套会话)。""" + + def __init__(self, config: dict): + # config 为 test_config.yaml 的 yun8_api section + self.base_url = config["base_url"].rstrip("/") + self.verify_ssl = config.get("verify_ssl", False) + self.timeout = config.get("timeout_seconds", 60) + common = config.get("common_headers", {}) + + # ---- Driver Session ---- + self.driver_http = BaseHttpClient( + self.base_url, headers=config.get("driver_headers", {}), + verify_ssl=self.verify_ssl, timeout=self.timeout, name="yun8-driver") + + # ---- Admin Session ---- + self.admin_http = BaseHttpClient( + self.base_url, headers=config.get("admin_headers", {}), + verify_ssl=self.verify_ssl, timeout=self.timeout, name="yun8-admin") + + # 通用请求头无条件覆盖(与旧版一致,即使为空也写入) + for http in (self.driver_http, self.admin_http): + http.set_header("host", common.get("host", "")) + http.set_header("accept-language", common.get("accept-language", "")) + http.set_header("accept-encoding", common.get("accept-encoding", "")) + + # ---- Third-party Session (滇运通,独立 token/host) ---- + tp = config.get("dianyuntong", {}) + self.tp_enabled = tp.get("enabled", False) + self.tp_http = None + self.tp_base = "" + if self.tp_enabled: + self.tp_base = tp.get("base_url", "").rstrip("/") + tp_headers = { + "host": "yndyt.cxtech.vip", + "authorization": tp.get("token", ""), + "deviceid": "0000000000", + "secret": "GouldAMap=d58bb5f7da1aa1d6b78741ded2450f2d", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0", + "accept": "application/json, text/plain, */*", + "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", + "origin": tp.get("base_url", ""), + "referer": f"{tp.get('base_url', '')}/admin/", + "cookie": tp.get("cookie", ""), + } + self.tp_http = BaseHttpClient( + self.tp_base, headers=tp_headers, + verify_ssl=self.verify_ssl, timeout=self.timeout, name="yun8-tp") + + # 快捷引用(各域 Mixin 使用) + self.dp = config.get("driver_params", {}) + self.ac = config.get("accounting", {}) + self.pm = config.get("payment", {}) + + # ------------------------------------------------------------------ + def set_driver_auth(self, token: str): + self.driver_http.set_header("authorization", token) + + def set_admin_auth(self, token: str): + self.admin_http.set_header("authorization", token) + + def close(self): + """关闭全部会话连接池。""" + self.driver_http.close() + self.admin_http.close() + if self.tp_http: + self.tp_http.close() diff --git a/tests/apis/yun8/driver_api.py b/tests/apis/yun8/driver_api.py new file mode 100644 index 0000000..90d6991 --- /dev/null +++ b/tests/apis/yun8/driver_api.py @@ -0,0 +1,163 @@ +"""司机端接口 Mixin — step01-05(对应 Postman Collection 司机端 5 个接口)。 + +所有方法签名/返回值/默认值与旧 yun8_sync/client.py 逐字一致, +仅将原始 requests.Session 调用替换为 BaseHttpClient(self.driver_http)。 + +Usage: + from tests.apis.yun8.driver_api import DriverApi + class Yun8ApiClient(DriverApi, ...): ... +""" + +from datetime import datetime + +from tests.core.http_client import BaseHttpClient + + +def _current_time_str() -> str: + """Postman pre-request: 生成 'YYYY-MM-DD HH:MM:SS' 格式的当前时间""" + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +class DriverApi: + """司机端接口(挂载于 Yun8ApiClient,依赖 driver_http / dp 属性)。""" + + # ================================================================== + # Step 01: 查询货源 + # ================================================================== + def step01_query_goods(self, page_size: int = 10, page_num: int = 1) -> dict: + """ + Postman Test Script: + pm.collectionVariables.set("mainOrderCode", jsonData.data.records[0].code); + pm.collectionVariables.set("residueNumber", jsonData.data.records[0].lastWeight); + + Returns: {"mainOrderCode": str, "residueNumber": str} + """ + params = { + "isFromOrderHall": "1", + "sortType": "desc", + "sortField": "gmt_created", + "pageSize": page_size, + "pageNum": page_num, + } + data = self.driver_http.get("/ntocc-tms-api/order/orderHallList", params) + records = data.get("data", {}).get("records", []) + if not records: + raise RuntimeError("查询货源: 无记录返回") + + r = records[0] + return { + "mainOrderCode": r.get("code", ""), + "residueNumber": str(r.get("lastWeight", "")), + "shipperLatitude": str(r.get("shipperLatitude", "")), + "shipperLongitude": str(r.get("shipperLongitude", "")), + "consigneeLatitude": str(r.get("consigneeLatitude", "")), + "consigneeLongitude": str(r.get("consigneeLongitude", "")), + } + + # ================================================================== + # Step 02: 接单 + # ================================================================== + def step02_accept_order(self, main_order_code: str, residue_number: str, + message_code: str = "", + latitude: str = None, longitude: str = None) -> dict: + """ + Postman Body (urlencoded): + residueNumber / perWeight / latitude / messageCode / mainOrderCode / longitude + latitude/longitude 优先取货源接口的 shipperLat/shipperLng,没有则用 config + """ + data = { + "residueNumber": residue_number, + "perWeight": self.dp.get("per_weight", "1"), + "latitude": latitude or self.dp.get("latitude", ""), + "messageCode": message_code, + "mainOrderCode": main_order_code, + "longitude": longitude or self.dp.get("longitude", ""), + } + return self.driver_http.post("/ntocc-tms-api/orderDetail/receive", data=data) + + # ================================================================== + # Step 03: 查询待运输单 + # ================================================================== + def step03_query_pending_transport(self, page_size: int = 10, page_num: int = 1) -> dict: + """ + Postman Test Script: + pm.collectionVariables.set("code", jsonData.data.records[0].code); + pm.collectionVariables.set("vehicleCode", jsonData.data.records[0].vehicleCode); + pm.collectionVariables.set("orderCode", jsonData.data.records[0].orderCode); + + Returns: {"code": str, "vehicleCode": str, "orderCode": str} + """ + params = { + "assignmentStatus": "waitTrans", + "placeOfReceipt": "", + "gmtCreatedEnd": "", + "gmtCreatedStart": "", + "pageSize": page_size, + "placeOfShipment": "", + "pageNum": page_num, + "shipmentName": "", + "status": "1", + "vehicleLicense": "", + } + data = self.driver_http.get("/ntocc-tms-api/orderDetail", params) + records = data.get("data", {}).get("records", []) + if not records: + raise RuntimeError("查询待运输单: 无记录返回") + + r = records[0] + return { + "code": r.get("code", ""), + "vehicleCode": r.get("vehicleCode", ""), + "orderCode": r.get("orderCode", ""), + } + + # ================================================================== + # Step 04: 装货 + # ================================================================== + def step04_load_goods(self, code: str, vehicle_code: str, + img_url: str = "", quantity: str = "", + weight: str = None, + latitude: str = None, longitude: str = None) -> dict: + """ + Postman Pre-request Script: 生成 currentTime + Body: imgUrl / orderType / quantity / code / latitude / weight / vehicleCode / time / longitude + latitude/longitude 优先取货源接口的 shipperLat/shipperLng + """ + if weight is None: + weight = self.dp.get("weight", "1") + body = { + "imgUrl": img_url or self.dp.get("load_img_url", ""), + "orderType": "1", + "quantity": quantity, + "code": code, + "latitude": latitude or self.dp.get("latitude", ""), + "weight": weight, + "vehicleCode": vehicle_code, + "time": _current_time_str(), + "longitude": longitude or self.dp.get("longitude", ""), + } + return self.driver_http.put("/ntocc-tms-api/orderDetail/fill", data=body) + + # ================================================================== + # Step 05: 卸货 + # ================================================================== + def step05_unload_goods(self, code: str, img_url: str = "", + quantity: str = "", weight: str = None, + latitude: str = None, longitude: str = None) -> dict: + """ + Postman Pre-request Script: 生成 currentTime + Body: imgUrl / code / quantity / latitude / weight / time / longitude + latitude/longitude 优先取货源接口的 consigneeLat/consigneeLng + """ + if weight is None: + weight = self.dp.get("weight", "1") + body = { + "imgUrl": img_url or self.dp.get("unload_img_url", ""), + "code": code, + "quantity": quantity, + "latitude": latitude or self.dp.get("latitude", ""), + "weight": weight, + "time": _current_time_str(), + "longitude": longitude or self.dp.get("longitude", ""), + } + return self.driver_http.put("/ntocc-tms-api/orderDetail/uploadTicketImage", data=body) diff --git a/yun8_sync/pipeline.py b/tests/apis/yun8/pipeline.py similarity index 69% rename from yun8_sync/pipeline.py rename to tests/apis/yun8/pipeline.py index c71561b..e63d619 100644 --- a/yun8_sync/pipeline.py +++ b/tests/apis/yun8/pipeline.py @@ -1,29 +1,58 @@ -""" -流程编排器 -========== -将 Postman Collection 中 16 步运输流程 + SQL 写入串联为完整流水线。 +"""yun8 运输流程编排器。 -变量传递完全模拟 Postman 的 Collection Variables 机制: - 步骤 N 的 output 传递给后续步骤作为 input。 +将 Postman Collection 中运输流程 18 个接口 + SQL 写入串联为完整流水线, +变量传递模拟 Postman 的 Collection Variables 机制(步骤 N 的输出写入 +self.vars,供后续步骤读取)。 -用法: - from pipeline import Pipeline - pipe = Pipeline(config) - # 仅跑司机端 (步骤 1-5) - result = pipe.run_driver_flow() - # 完整流程 (步骤 1-16 + SQL) - result = pipe.run_full_flow(dry_run=False) +与旧 yun8_sync/pipeline.py 的差异(均为迁移计划内的有意行为变化): + - _check_response 业务 code != 0 时抛 Yun8StepError(旧版仅 print) + - step17 未提取到 payNo 时抛 Yun8StepError(旧版警告后继续,写库 front_seq_no 为空) + - step20 上报运单 / step21 滇运通审核失败不再吞异常,向上传播 + - SQL 全部参数化,reset 序列包进事务,连接复用 + - print → 统一 logger(output/logs/ 文件 + 控制台) + +Usage: + from tests.apis.yun8.pipeline import Yun8Pipeline + pipe = Yun8Pipeline(config["yun8_api"], db) + pipe.set_auth(driver_token, admin_token) + pipe.run_driver_flow() # 步骤 1-5 """ -import json import random import time import uuid from datetime import datetime, timedelta -from typing import Optional -from client import Yun8Client -from db import Database +from tests.core.db_helper import Db +from tests.core.logger import get_logger + +from tests.apis.yun8.client import Yun8ApiClient +from tests.apis.yun8.sql_templates import ( + RECEIPT_FILE_INSERT_SQL, + ETC_INVOICE_INSERT_SQL, + ETC_PUSH_INSERT_SQL, + etc_invoice_insert_params, + etc_status_update, + post_unload_reset_update, + receipt_file_insert_params, + driver_reset_statements, + risk_control_update, +) + + +class Yun8StepError(RuntimeError): + """yun8 流程步骤失败(业务 code != 0 或关键变量提取失败)。""" + + def __init__(self, step_name: str, code=None, msg: str = ""): + self.step_name = step_name + self.code = code + self.msg = msg + detail = f"{step_name} 失败" + if code is not None: + detail += f" (code={code}, msg={msg})" + elif msg: + detail += f" ({msg})" + super().__init__(detail) def _gen_code() -> str: @@ -39,29 +68,33 @@ _ETC_STATION_CHAIN = [ # ============================================================ -# Pipeline — 变量上下文 +# Yun8Pipeline — 变量上下文 # ============================================================ -class Pipeline: +class Yun8Pipeline: """ 模拟 Postman Collection Variables 的变量上下文。 每个步骤的输出写入 self.vars,后续步骤从 self.vars 读取。 """ - def __init__(self, config: dict): + def __init__(self, config: dict, db: Db): + # config 为 test_config.yaml 的 yun8_api section self.config = config - self.client = Yun8Client(config) - self.db = Database(config) + self.client = Yun8ApiClient(config) + self.db = db + self.log = get_logger("apis.yun8.pipeline") # 所有步骤间共享的变量池(对应 Postman collectionVariables) self.vars: dict = {} + # 回单 file_name 格式模板(旧 config.sql.file_name_template) + self.file_name_template = config.get("sql", {}).get( + "file_name_template", "{file_prefix}_1_signed.pdf") # 动态 weight 模式:每次循环随机生成 1~10 之间的小数,per_weight 和 weight 保持一致 dp = config.get("driver_params", {}) if dp.get("weight_mode", "fixed") == "random": - import random as _r - val = str(round(_r.uniform(1.0, 10.0), 1)) + val = str(round(random.uniform(1.0, 10.0), 1)) self.client.dp["per_weight"] = val self.client.dp["weight"] = val - print(f" [random] weight = {val}") + self.log.info(f" [random] weight = {val}") # ------------------------------------------------------------------ # 辅助方法 @@ -75,16 +108,16 @@ class Pipeline: return self.vars.get(key, default) def _log_step(self, num: int, name: str): - print(f"\n{'─' * 50}") - print(f"[Step {num:02d}] {name}") - print(f"{'─' * 50}") + self.log.info(f"\n{'─' * 50}") + self.log.info(f"[Step {num:02d}] {name}") + self.log.info(f"{'─' * 50}") def _check_response(self, data: dict, step_name: str): - """检查 API 返回 code 是否为 0""" + """检查 API 返回 code 是否为 0,非 0 视为步骤失败并中断流程。""" code = data.get("code") msg = data.get("msg", "") if code is not None and code != 0: - print(f" [!] {step_name} 返回 code={code}, msg={msg}") + raise Yun8StepError(step_name, code=code, msg=msg) def _load_lat(self) -> str: """装货纬度: config.load_latitude → 货源 shipperLatitude → config.latitude""" @@ -118,8 +151,8 @@ class Pipeline: shipperLongitude=result["shipperLongitude"], consigneeLatitude=result["consigneeLatitude"], consigneeLongitude=result["consigneeLongitude"]) - print(f" mainOrderCode = {result['mainOrderCode']}") - print(f" residueNumber = {result['residueNumber']}") + self.log.info(f" mainOrderCode = {result['mainOrderCode']}") + self.log.info(f" residueNumber = {result['residueNumber']}") return result def step02_accept_order(self) -> dict: @@ -130,7 +163,7 @@ class Pipeline: latitude=self._load_lat(), longitude=self._load_lng()) self._check_response(data, "接单") - print(f" 响应: code={data.get('code')}, msg={data.get('msg')}") + self.log.info(f" 响应: code={data.get('code')}, msg={data.get('msg')}") return data def step03_query_pending_transport(self) -> dict: @@ -138,9 +171,9 @@ class Pipeline: result = self.client.step03_query_pending_transport() self._set_vars(code=result["code"], vehicleCode=result["vehicleCode"], orderCode=result["orderCode"]) - print(f" code = {result['code']}") - print(f" vehicleCode = {result['vehicleCode']}") - print(f" orderCode = {result['orderCode']}") + self.log.info(f" code = {result['code']}") + self.log.info(f" vehicleCode = {result['vehicleCode']}") + self.log.info(f" orderCode = {result['orderCode']}") return result def step04_load_goods(self) -> dict: @@ -151,7 +184,7 @@ class Pipeline: latitude=self._load_lat(), longitude=self._load_lng()) self._check_response(data, "装货") - print(f" 响应: code={data.get('code')}, msg={data.get('msg')}") + self.log.info(f" 响应: code={data.get('code')}, msg={data.get('msg')}") return data def step05_unload_goods(self) -> dict: @@ -161,7 +194,7 @@ class Pipeline: latitude=self._unload_lat(), longitude=self._unload_lng()) self._check_response(data, "卸货") - print(f" 响应: code={data.get('code')}, msg={data.get('msg')}") + self.log.info(f" 响应: code={data.get('code')}, msg={data.get('msg')}") return data # ================================================================== @@ -173,7 +206,7 @@ class Pipeline: order_code=self._get("orderCode")) for k, v in record.items(): self.vars[k] = str(v) if v is not None else "" - print(f" 提取字段 ({len(record)}): {', '.join(list(record.keys())[:10])}...") + self.log.info(f" 提取字段 ({len(record)}): {', '.join(list(record.keys())[:10])}...") return record def step07_query_order_expenses(self) -> dict: @@ -181,7 +214,7 @@ class Pipeline: result = self.client.step07_query_order_expenses(code=self._get("code")) self._set_vars(**result) for k, v in result.items(): - print(f" {k} = {v}") + self.log.info(f" {k} = {v}") return result def step08_calculate_order_detail(self) -> dict: @@ -189,14 +222,14 @@ class Pipeline: result = self.client.step08_calculate_order_detail(code=self._get("code")) self._set_vars(**result) for k, v in result.items(): - print(f" {k} = {v[:80] if len(str(v)) > 80 else v}") + self.log.info(f" {k} = {v[:80] if len(str(v)) > 80 else v}") return result def step09_submit_accounting(self) -> dict: self._log_step(9, "核算提交 (orderHandleEditSubmitHandle)") data = self.client.step09_submit_accounting(self.vars) self._check_response(data, "核算提交") - print(f" 响应: code={data.get('code')}, msg={data.get('msg')}") + self.log.info(f" 响应: code={data.get('code')}, msg={data.get('msg')}") return data # ================================================================== @@ -207,14 +240,14 @@ class Pipeline: data = self.client.step10_query_audit(order_code=self._get("orderCode")) self._check_response(data, "查询运单审核") records = data.get("data", {}).get("records", []) - print(f" 记录数: {len(records)}") + self.log.info(f" 记录数: {len(records)}") return data def step11_approve_audit(self) -> dict: self._log_step(11, "核算审核通过") data = self.client.step11_approve_audit(code=self._get("code")) self._check_response(data, "核算审核通过") - print(f" 响应: code={data.get('code')}, msg={data.get('msg')}") + self.log.info(f" 响应: code={data.get('code')}, msg={data.get('msg')}") return data # Step 12-14 wrappers 不单独定义(已内联在 run_audit_payment_flow 中) @@ -228,7 +261,7 @@ class Pipeline: plan_driver_payout=self._get("planDriverPayout", "0"), plan_captain_payout=self._get("planCaptainPayout", "0")) self._check_response(data, "垫资-财务打款") - print(f" 响应: code={data.get('code')}, msg={data.get('msg')}") + self.log.info(f" 响应: code={data.get('code')}, msg={data.get('msg')}") return data def step16_apply_for_payment(self) -> dict: @@ -237,7 +270,7 @@ class Pipeline: code=self._get("code"), total_amount=self._get("shipmentActualCash")) self._check_response(data, "垫资-货主打款") - print(f" 响应: code={data.get('code')}, msg={data.get('msg')}") + self.log.info(f" 响应: code={data.get('code')}, msg={data.get('msg')}") return data # ================================================================== @@ -257,9 +290,10 @@ class Pipeline: ) pay_no = result["payNo"] self._set_vars(payNo=pay_no) - print(f" payNo = {pay_no}") + self.log.info(f" payNo = {pay_no}") if not pay_no: - print(" [!] 未提取到 payNo,后续 SQL 中 front_seq_no 将为空") + # 迁移行为变化:旧版仅警告并继续(INSERT 空 front_seq_no),现视为失败 + raise Yun8StepError("查询运单上传", msg="未提取到 payNo(sendMsg 中无【】包裹内容)") return result def step18_query_remit_records(self) -> list[dict]: @@ -274,9 +308,9 @@ class Pipeline: records = self.client.step18_query_remit_records( order_code=self._get("orderCode"), ) - print(f" 共 {len(records)} 条打款记录") + self.log.info(f" 共 {len(records)} 条打款记录") if records: - print(f" 首条字段: {list(records[0].keys())}") + self.log.info(f" 首条字段: {list(records[0].keys())}") return records def step19_query_file_prefix(self, remit_code: str) -> str: @@ -295,9 +329,9 @@ class Pipeline: def run_driver_flow(self) -> dict: """仅跑司机端(步骤 1-5)""" - print("\n" + "=" * 60) - print(" 司机端流程 (Step 1-5)") - print("=" * 60) + self.log.info("\n" + "=" * 60) + self.log.info(" 司机端流程 (Step 1-5)") + self.log.info("=" * 60) self.step01_query_goods() time.sleep(0.3) @@ -312,16 +346,12 @@ class Pipeline: if dp.get("reset_order_detail", False): code = self._get("code") order_code = self._get("orderCode") - print(f"\n [reset] 重置运单 orderCode={order_code}") + self.log.info(f"\n [reset] 重置运单 orderCode={order_code}") reset_code = dp.get("reset_order_detail_code", "") - for sql in [ - f"DELETE FROM tms_order_detail WHERE code = '{reset_code}'", - f"DELETE FROM tms_order_detail_ext WHERE order_detail_code = '{reset_code}'", - f"DELETE FROM tms_order_detail_abnormal_status WHERE order_detail_code = '{reset_code}'", - f"UPDATE tms_order_detail SET code = '{reset_code}' WHERE order_code = '{order_code}'", - ]: - rows = self.db.execute(sql) - print(f" SQL: {sql[:80]}... → {rows} rows") + with self.db.transaction(): + for sql, params in driver_reset_statements(reset_code, order_code): + rows = self.db.execute(sql, params) + self.log.info(f" SQL: {self.db.mogrify(sql, params)[:80]}... → {rows} rows") time.sleep(0.3) # 重新查询待运输单,刷新变量 self.step03_query_pending_transport() @@ -336,27 +366,23 @@ class Pipeline: # 卸货后 SQL if dp.get("reset_order_detail", False): reset_code = dp.get("reset_order_detail_code", "") - sql = (f"UPDATE tms_order_detail SET code = '{reset_code}', " - f"fill_time = '2026-07-05 19:30:56', " - f"sign_time = '2026-07-05 19:52:30' " - f"WHERE order_code = '{self._get('orderCode')}'") - rows = self.db.execute(sql) - print(f" [reset] 卸货后 UPDATE: {rows} rows") + sql, params = post_unload_reset_update(reset_code, self._get("orderCode")) + rows = self.db.execute(sql, params) + self.log.info(f" [reset] 卸货后 UPDATE: {rows} rows") else: - print(" [...] 等待 10s ...") + self.log.info(" [...] 等待 10s ...") time.sleep(10) - sql = (f"UPDATE tms_order_detail_ext SET risk_control_status = 1 " - f"WHERE order_detail_code = '{self._get('code')}'") - rows = self.db.execute(sql) - print(f" 卸货后 risk_control_status UPDATE: {rows} rows") + sql, params = risk_control_update(self._get("code")) + rows = self.db.execute(sql, params) + self.log.info(f" 卸货后 risk_control_status UPDATE: {rows} rows") return self.vars def run_accounting_flow(self) -> dict: """运费核算流程(步骤 6-9)""" - print("\n" + "=" * 60) - print(" 运费核算流程 (Step 6-9)") - print("=" * 60) + self.log.info("\n" + "=" * 60) + self.log.info(" 运费核算流程 (Step 6-9)") + self.log.info("=" * 60) self.step06_query_for_accounting() time.sleep(0.5) @@ -384,7 +410,7 @@ class Pipeline: return data if "处理中" in msg or "稍后" in msg: wait = 3 + attempt * 2 # 5, 7, 9, 11, 13 秒 - print(f" [...] {step_name} 服务端正处理中,第 {attempt}/{max_retries} 次重试(等待 {wait}s)...") + self.log.info(f" [...] {step_name} 服务端正处理中,第 {attempt}/{max_retries} 次重试(等待 {wait}s)...") time.sleep(wait) else: # 非 "处理中" 错误,直接返回 @@ -393,9 +419,9 @@ class Pipeline: def run_audit_payment_flow(self) -> dict: """审核 & 付款流程(步骤 10-16)""" - print("\n" + "=" * 60) - print(" 审核 & 付款流程 (Step 10-16)") - print("=" * 60) + self.log.info("\n" + "=" * 60) + self.log.info(" 审核 & 付款流程 (Step 10-16)") + self.log.info("=" * 60) # 确保前置依赖已获取(独立运行 --stage payment 时 vars 可能为空) self._ensure_code_and_order_code() @@ -404,11 +430,11 @@ class Pipeline: time.sleep(0.5) self.step11_approve_audit() - print(" [...] 等待服务端处理审核结果...") + self.log.info(" [...] 等待服务端处理审核结果...") ac = self.config.get("accounting", {}) if not ac.get("execute_after_audit", True): - print(f"\n [stop] execute_after_audit=false,跳过步骤 12-21") + self.log.info(f"\n [stop] execute_after_audit=false,跳过步骤 12-21") return self.vars # ---- 队长分账 (条件触发:payerReceiverPair 非空 + captainSplitMode=2) ---- @@ -423,8 +449,8 @@ class Pipeline: self._check_response(preview, "队长分账预览") latest_driver = float(preview.get("data", {}).get("latestDriverAmount", 0)) oil_fee = float(preview.get("data", {}).get("deliveryOilFee", 0)) - print(f" latestDriverAmount = {latest_driver}") - print(f" deliveryOilFee = {oil_fee}") + self.log.info(f" latestDriverAmount = {latest_driver}") + self.log.info(f" deliveryOilFee = {oil_fee}") time.sleep(0.5) self._log_step(13, "队长分账调整 (adjustCaptainSplit)") @@ -438,14 +464,14 @@ class Pipeline: delivery_oil_fee=oil_fee, ) self._check_response(adjust, "队长分账调整") - print(f" 响应: code={adjust.get('code')}, msg={adjust.get('msg')}") + self.log.info(f" 响应: code={adjust.get('code')}, msg={adjust.get('msg')}") # 存入 vars,供 step13 财务打款使用 self._set_vars(planDriverPayout=str(_driver_amt), planCaptainPayout=str(_captain_amt)) - print(f" planDriverPayout = {_driver_amt}") - print(f" planCaptainPayout = {_captain_amt}") + self.log.info(f" planDriverPayout = {_driver_amt}") + self.log.info(f" planCaptainPayout = {_captain_amt}") else: - print(f" [skip] 队长分账 (payerReceiver='{payer_receiver}', captainSplitMode={captain_split_mode})") + self.log.info(f" [skip] 队长分账 (payerReceiver='{payer_receiver}', captainSplitMode={captain_split_mode})") time.sleep(0.5) # ---- Step 14: singleOil 带重试 ---- @@ -459,7 +485,7 @@ class Pipeline: "单油卡充值", ) self._check_response(data_12, "singleOil") - print(f" 响应: code={data_12.get('code')}, msg={data_12.get('msg')}") + self.log.info(f" 响应: code={data_12.get('code')}, msg={data_12.get('msg')}") # Step 13 带重试 self._log_step(15, "垫资-财务打款") @@ -474,7 +500,7 @@ class Pipeline: "垫资-财务打款", ) self._check_response(data_12, "垫资-财务打款") - print(f" 响应: code={data_12.get('code')}, msg={data_12.get('msg')}") + self.log.info(f" 响应: code={data_12.get('code')}, msg={data_12.get('msg')}") # Step 14 带重试 (totalAmount 取 step06 的 shipmentActualCash) self._log_step(16, "垫资-货主打款 (申请付款)") @@ -486,15 +512,15 @@ class Pipeline: "垫资-货主打款", ) self._check_response(data_13, "垫资-货主打款") - print(f" 响应: code={data_13.get('code')}, msg={data_13.get('msg')}") + self.log.info(f" 响应: code={data_13.get('code')}, msg={data_13.get('msg')}") return self.vars def run_receipt_file_flow(self, dry_run: bool = False) -> list[dict]: """回单文件流程(步骤 15-18)+ SQL INSERT""" - print("\n" + "=" * 60) - print(" 回单文件流程 (Step 14-16 + SQL)") - print("=" * 60) + self.log.info("\n" + "=" * 60) + self.log.info(" 回单文件流程 (Step 14-16 + SQL)") + self.log.info("=" * 60) # 确保 orderCode 已就绪(独立运行 --stage receipt 时需要) self._ensure_code_and_order_code() @@ -507,7 +533,7 @@ class Pipeline: remit_records = self.step18_query_remit_records() if not remit_records: - print(" [EXIT] 无打款记录") + self.log.info(" [EXIT] 无打款记录") return [] # 16 + SQL: 遍历每条打款记录 @@ -519,75 +545,72 @@ class Pipeline: for i, rec in enumerate(remit_records): remit_code = rec.get("code", "") if not remit_code: - print(f"\n [{i+1}/{len(remit_records)}] 跳过:缺 code 字段") + self.log.info(f"\n [{i+1}/{len(remit_records)}] 跳过:缺 code 字段") ng += 1 results.append({"remit_code": remit_code, "status": "skip_no_code"}) continue - print(f"\n [{i+1}/{len(remit_records)}] remitCode = {remit_code}") + self.log.info(f"\n [{i+1}/{len(remit_records)}] remitCode = {remit_code}") # 16. 查询文件名前缀 file_prefix = self.step19_query_file_prefix(remit_code) - print(f" filePrefix = {file_prefix}") + self.log.info(f" filePrefix = {file_prefix}") if not file_prefix: - print(f" [!] filePrefix 为空,跳过 SQL INSERT") + self.log.info(f" [!] filePrefix 为空,跳过 SQL INSERT") ng += 1 results.append({"remit_code": remit_code, "status": "skip_no_prefix"}) continue - file_name = self.db.file_name_template.format(file_prefix=file_prefix) - print(f" file_name = {file_name}") - print(f" payNo = {pay_no}") + file_name = self.file_name_template.format(file_prefix=file_prefix) + self.log.info(f" file_name = {file_name}") + self.log.info(f" payNo = {pay_no}") - # SQL INSERT - sql = self.db.build_sql(file_prefix, pay_no) + # SQL INSERT(参数化;dry-run 用 mogrify 渲染预览) + sql = RECEIPT_FILE_INSERT_SQL + params = receipt_file_insert_params(file_prefix, pay_no, + self.file_name_template) if dry_run: - print(f" [DRY RUN] SQL:\n{sql}") + self.log.info(f" [DRY RUN] SQL:\n{self.db.mogrify(sql, params)}") ok += 1 results.append({"remit_code": remit_code, "file_prefix": file_prefix, "status": "dry_run"}) else: try: - rowcount = self.db.insert(file_prefix, pay_no) - print(f" [OK] INSERT 成功 (rows: {rowcount})") + rowcount = self.db.execute(sql, params) + self.log.info(f" [OK] INSERT 成功 (rows: {rowcount})") ok += 1 results.append({"remit_code": remit_code, "file_prefix": file_prefix, "status": "inserted", "rows": rowcount}) except Exception as exc: - print(f" [FAIL] INSERT 失败: {exc}") + self.log.info(f" [FAIL] INSERT 失败: {exc}") ng += 1 results.append({"remit_code": remit_code, "file_prefix": file_prefix, "status": "error", "error": str(exc)}) # 汇总 - print(f"\n ───────────────────────") - print(f" 回单文件流程完成: 成功 {ok}, 失败 {ng}, 总计 {len(remit_records)}") + self.log.info(f"\n ───────────────────────") + self.log.info(f" 回单文件流程完成: 成功 {ok}, 失败 {ng}, 总计 {len(remit_records)}") - # Step 18: 上报运单 + # Step 18: 上报运单(迁移行为变化:失败不再吞异常,向上传播) self._log_step(20, "上报运单 (reportOrderDetail)") - try: - data_17 = self.client.step20_report_order_detail( - codes=self._get("code"), - ) - self._check_response(data_17, "上报运单") - print(f" 响应: code={data_17.get('code')}, msg={data_17.get('msg')}") - except Exception as exc: - print(f" [!] 上报运单失败: {exc}") + data_17 = self.client.step20_report_order_detail( + codes=self._get("code"), + ) + self._check_response(data_17, "上报运单") + self.log.info(f" 响应: code={data_17.get('code')}, msg={data_17.get('msg')}") # Step 19: 滇运通审核 (独立 token/host,config 控制开关) + # (迁移行为变化:失败不再吞异常,向上传播) if self.client.tp_enabled: self._log_step(21, "滇运通审核 (updateOrderDetailExt)") - try: - data_19 = self.client.step21_owner_review( - order_detail_code=self._get("code"), - ) - self._check_response(data_19, "滇运通审核") - print(f" 响应: code={data_19.get('code')}, msg={data_19.get('msg')}") - except Exception as exc: - print(f" [!] 滇运通审核失败: {exc}") + data_19 = self.client.step21_owner_review( + order_detail_code=self._get("code"), + ) + self._check_response(data_19, "滇运通审核") + self.log.info(f" 响应: code={data_19.get('code')}, msg={data_19.get('msg')}") else: - print(f"\n [skip] Step 19 滇运通审核 (dianyuntong.enabled=false)") + self.log.info(f"\n [skip] Step 19 滇运通审核 (dianyuntong.enabled=false)") return results @@ -642,18 +665,18 @@ class Pipeline: order_code = self._get("orderCode") detail_code = self._get("code") if not order_code or not detail_code: - print(" [!] orderCode/code 为空,无法写入 base_etc_invoice") + self.log.info(" [!] orderCode/code 为空,无法写入 base_etc_invoice") return [{"table": "base_etc_invoice", "status": "skip_no_vars"}] raw_count = self.config.get("etc_invoice", {}).get("count", 3) n = int(raw_count) if raw_count is not None else 3 push_order_no = f"{order_code}-1" - print(f" order_code = {order_code}") - print(f" push_order_no = {push_order_no}") - print(f" order_detail_code = {detail_code}") - print(f" 发票条数 = {n}") + self.log.info(f" order_code = {order_code}") + self.log.info(f" push_order_no = {push_order_no}") + self.log.info(f" order_detail_code = {detail_code}") + self.log.info(f" 发票条数 = {n}") if n <= 0: - print(" [skip] etc_invoice.count <= 0,跳过") + self.log.info(" [skip] etc_invoice.count <= 0,跳过") return [] # ---- 金额约束: 所有发票金额合计 ≤ 运费 × 50% ---- @@ -666,9 +689,9 @@ class Pipeline: else "shipmentActualFreight") cap = freight * 0.5 cap_floor = int(cap * 100) / 100 # 向下取整到分,确保不超上限 - print(f" 运费({freight_src}) = {freight:.2f},发票金额上限(运费×50%) = {cap_floor:.2f}") + self.log.info(f" 运费({freight_src}) = {freight:.2f},发票金额上限(运费×50%) = {cap_floor:.2f}") if cap_floor < 0.01 * n: - print(f" [!] 上限 {cap_floor:.2f} 不足以分配 {n} 张发票(每张至少 0.01),跳过发票写入") + self.log.info(f" [!] 上限 {cap_floor:.2f} 不足以分配 {n} 张发票(每张至少 0.01),跳过发票写入") return [{"table": "base_etc_invoice", "status": "skip_amount_cap"}] # 随机总额: 上限的 30%~100%,且保证每张至少 0.01;按整数分分配,合计精确 @@ -686,59 +709,41 @@ class Pipeline: amounts = [(1 + a) / 100 for a in alloc] # 写入前显式判断(按构造不应触发,作兜底保护) if round(sum(amounts), 2) > round(cap, 2): - print(f" [!] 发票合计 {sum(amounts):.2f} 超过运费×50% {cap:.2f},跳过发票写入") + self.log.info(f" [!] 发票合计 {sum(amounts):.2f} 超过运费×50% {cap:.2f},跳过发票写入") return [{"table": "base_etc_invoice", "status": "skip_amount_cap"}] - print(f" 发票金额合计 = {sum(amounts):.2f}({n} 张,各 {['%.2f' % a for a in amounts]})≤ 上限,校验通过") + self.log.info(f" 发票金额合计 = {sum(amounts):.2f}({n} 张,各 {['%.2f' % a for a in amounts]})≤ 上限,校验通过") results = [] for i in range(n): inv = self._gen_etc_invoice(i, n, amounts[i]) - sql = ( - "INSERT INTO `ntocc`.`base_etc_invoice` (" - "`id`, `code`, `order_code`, `push_order_no`, `order_detail_code`, " - "`invoice_time`, `sale_tax_code`, `sale_name`, `import_station`, `export_station`, " - "`transaction_time`, `total_price`, `amount`, `tax_rate`, `tax_amount`, " - "`invoice_type`, `invoice_code`, `invoice_number`, `waybill_start_time`, `waybill_end_time`, " - "`invoice_url`, `invoice_html_url`, `en_date`, `ex_date`, `ex_time`, " - "`is_valid`, `task_name`, `plate_num`, `etc_invoice_status`, `tax_deduction_status`, " - "`sign`, `is_deleted`, `created_by`, `modified_by`, `gmt_created`, `gmt_modified`) " - "VALUES (" - f"NULL, '{inv['code']}', '{order_code}', '{push_order_no}', '{detail_code}', " - f"'{inv['invoice_time']}', '9141010055833160XE', '河南省视博电子股份有限公司', " - f"'{inv['import_station']}', '{inv['export_station']}', " - f"'{inv['transaction_time']}', {inv['total_price']:.2f}, {inv['amount']:.2f}, 0.03, 0.03, " - f"'征税发票', '*', '{inv['invoice_number']}', '2026-05-13 15:35:38', '2026-05-15 13:35:43', " - "'https://hdfs.txffp.com/e/1102e1en66nPPP1ChAeC161e1C6Pei16.zip?Expires=1812122633&OSSAccessKeyId=LTAIYAJbICRhyCD8&Signature=TLjlHgKtCxwWpPliPRQ0xQ6HdKY%3D', " - "'https://gateway.txffp.com/alle/common/htmlurl/getinvoice/41012526221804003093/1102e1en66nPPP1ChAeC161e1C6Pei16', " - f"'{inv['en_date']}', '{inv['ex_date']}', '{inv['ex_time']}', " - "0, 'etc开票申请2026070604', '冀D8E016', 3, 0, " - "NULL, 0, '', '04b7e539c77d4bdbb4fe4e87eaaf41e4', '2026-07-06 09:55:16', '2026-07-06 10:03:26')" - ) - print(f" [{i+1}/{n}] 发票号={inv['invoice_number']} 金额={inv['total_price']:.2f} " - f"{inv['import_station']}→{inv['export_station']} " - f"交易时间={inv['transaction_time']} 开票时间={inv['invoice_time']}") + sql = ETC_INVOICE_INSERT_SQL + params = etc_invoice_insert_params(inv, order_code, push_order_no, + detail_code) + self.log.info(f" [{i+1}/{n}] 发票号={inv['invoice_number']} 金额={inv['total_price']:.2f} " + f"{inv['import_station']}→{inv['export_station']} " + f"交易时间={inv['transaction_time']} 开票时间={inv['invoice_time']}") if dry_run: - print(f" [DRY RUN] SQL:\n{sql}") + self.log.info(f" [DRY RUN] SQL:\n{self.db.mogrify(sql, params)}") results.append({"table": "base_etc_invoice", "invoice_number": inv["invoice_number"], "status": "dry_run"}) continue try: - rows = self.db.execute(sql) - print(f" [OK] INSERT 成功 (rows: {rows})") + rows = self.db.execute(sql, params) + self.log.info(f" [OK] INSERT 成功 (rows: {rows})") results.append({"table": "base_etc_invoice", "invoice_number": inv["invoice_number"], "status": "inserted", "rows": rows}) except Exception as exc: - print(f" [FAIL] INSERT 失败: {exc}") + self.log.info(f" [FAIL] INSERT 失败: {exc}") results.append({"table": "base_etc_invoice", "invoice_number": inv["invoice_number"], "status": "error", "error": str(exc)}) ok = sum(1 for r in results if r["status"] in ("inserted", "dry_run")) ng = len(results) - ok - print(f" base_etc_invoice 完成: 成功 {ok}, 失败 {ng}, 总计 {n}") + self.log.info(f" base_etc_invoice 完成: 成功 {ok}, 失败 {ng}, 总计 {n}") return results def step23_insert_etc_push(self, dry_run: bool = False) -> dict: @@ -755,39 +760,27 @@ class Pipeline: order_code = self._get("orderCode") detail_code = self._get("code") if not order_code or not detail_code: - print(" [!] orderCode/code 为空,无法写入 tms_order_etc_push") + self.log.info(" [!] orderCode/code 为空,无法写入 tms_order_etc_push") return {"table": "tms_order_etc_push", "status": "skip_no_vars"} etc_code = _gen_code() push_order_no = f"{order_code}-1" - sql = ( - "INSERT INTO `ntocc`.`tms_order_etc_push` (" - "`id`, `code`, `push_order_no`, `order_no`, `order_detail_code`, " - "`start_time`, `end_time`, `year`, `month`, `vendor`, " - "`request`, `start_request`, `end_request`, `invoice_pull_status`, `invoice_pull_time`, " - "`start_status`, `start_push_time`, `start_result`, `end_status`, `end_push_time`, `end_result`, " - "`is_deleted`, `created_by`, `modified_by`, `gmt_created`, `gmt_modified`) " - "VALUES (" - f"NULL, '{etc_code}', '{push_order_no}', '{order_code}', '{detail_code}', " - "'2026-07-07 15:08:25', '2026-07-07 15:08:28', 2026, 6, 1, " - "NULL, '11111', '11111', 2, '2026-07-07 11:40:21', " - "1, '2026-07-07 15:17:02', '成功', 1, '2026-07-07 15:11:04', '成功', " - "0, NULL, NULL, '2026-07-07 11:09:17', '2026-07-07 15:15:18')" - ) - print(f" order_no = {order_code}") - print(f" push_order_no = {push_order_no}") - print(f" order_detail_code = {detail_code}") - print(f" code = {etc_code}") + sql = ETC_PUSH_INSERT_SQL + params = (etc_code, push_order_no, order_code, detail_code) + self.log.info(f" order_no = {order_code}") + self.log.info(f" push_order_no = {push_order_no}") + self.log.info(f" order_detail_code = {detail_code}") + self.log.info(f" code = {etc_code}") if dry_run: - print(f" [DRY RUN] SQL:\n{sql}") + self.log.info(f" [DRY RUN] SQL:\n{self.db.mogrify(sql, params)}") return {"table": "tms_order_etc_push", "status": "dry_run"} try: - rows = self.db.execute(sql) - print(f" [OK] INSERT 成功 (rows: {rows})") + rows = self.db.execute(sql, params) + self.log.info(f" [OK] INSERT 成功 (rows: {rows})") return {"table": "tms_order_etc_push", "status": "inserted", "rows": rows} except Exception as exc: - print(f" [FAIL] INSERT 失败: {exc}") + self.log.info(f" [FAIL] INSERT 失败: {exc}") return {"table": "tms_order_etc_push", "status": "error", "error": str(exc)} def step24_update_order_etc_status(self, dry_run: bool = False) -> dict: @@ -800,29 +793,28 @@ class Pipeline: self._log_step(24, "更新运单 ETC 开票状态 (tms_order_detail.etc_invoice_status=3)") order_code = self._get("orderCode") if not order_code: - print(" [!] orderCode 为空,无法更新 tms_order_detail") + self.log.info(" [!] orderCode 为空,无法更新 tms_order_detail") return {"table": "tms_order_detail", "status": "skip_no_vars"} - sql = (f"UPDATE `ntocc`.`tms_order_detail` SET etc_invoice_status = 3 " - f"WHERE order_code = '{order_code}'") - print(f" order_code = {order_code}") + sql, params = etc_status_update(order_code) + self.log.info(f" order_code = {order_code}") if dry_run: - print(f" [DRY RUN] SQL:\n{sql}") + self.log.info(f" [DRY RUN] SQL:\n{self.db.mogrify(sql, params)}") return {"table": "tms_order_detail", "status": "dry_run"} try: - rows = self.db.execute(sql) - print(f" [OK] UPDATE 成功 (rows: {rows})") + rows = self.db.execute(sql, params) + self.log.info(f" [OK] UPDATE 成功 (rows: {rows})") return {"table": "tms_order_detail", "status": "updated", "rows": rows} except Exception as exc: - print(f" [FAIL] UPDATE 失败: {exc}") + self.log.info(f" [FAIL] UPDATE 失败: {exc}") return {"table": "tms_order_detail", "status": "error", "error": str(exc)} def run_etc_invoice_flow(self, dry_run: bool = False) -> list[dict]: """ETC 开票 SQL 写入(步骤 22-24)""" - print("\n" + "=" * 60) - print(" ETC 开票 SQL 写入 (Step 22-24)") - print("=" * 60) + self.log.info("\n" + "=" * 60) + self.log.info(" ETC 开票 SQL 写入 (Step 22-24)") + self.log.info("=" * 60) # 确保 orderCode / code 已就绪(独立运行 --stage receipt 时需要) self._ensure_code_and_order_code() @@ -842,13 +834,13 @@ class Pipeline: 若订单已过 driver 阶段则降级为 admin 端查询(step10 接口)。 """ if self._get("code") and self._get("orderCode"): - print(f" (复用已有 code={self._get('code')}, orderCode={self._get('orderCode')})") + self.log.info(f" (复用已有 code={self._get('code')}, orderCode={self._get('orderCode')})") return - print(" [*] 自动补充前置数据...") + self.log.info(" [*] 自动补充前置数据...") # 方式 A: 通过 driver 端 step03(status=waitTrans) - driver_auth = self.client.driver.headers.get("authorization", "") + driver_auth = self.client.driver_http.get_header("authorization") if driver_auth: try: result = self.client.step03_query_pending_transport() @@ -857,9 +849,9 @@ class Pipeline: vehicleCode=result["vehicleCode"], orderCode=result["orderCode"], ) - print(f" [driver] code={self._get('code')}, orderCode={self._get('orderCode')}") + self.log.info(f" [driver] code={self._get('code')}, orderCode={self._get('orderCode')}") except Exception: - print(" driver 端无待运输单,切换 admin 端查询...") + self.log.info(" driver 端无待运输单,切换 admin 端查询...") driver_auth = "" # 标记失败,走降级 # 方式 B: 通过 admin 端(status=6 已结算 / status=5 已打款) @@ -887,13 +879,14 @@ class Pipeline: orderCode=r.get("orderCode", ""), ) if self._get("code"): - print(f" [admin status={status_val}] code={self._get('code')}, orderCode={self._get('orderCode')}") + self.log.info(f" [admin status={status_val}] code={self._get('code')}, orderCode={self._get('orderCode')}") break except Exception as e: + self.log.debug(f" [admin status={status_val}] 查询失败: {e}") continue if not self._get("code"): - print(" [!] 无法自动获取 code,请使用 --stage all 完整运行或手动指定") + self.log.info(" [!] 无法自动获取 code,请使用 --stage all 完整运行或手动指定") return # 补充核算数据(payment 阶段需要 deliveryCashFee/driverActualFreight) @@ -908,13 +901,17 @@ class Pipeline: code=self._get("code"), ) self._set_vars(**expenses) - print(f" deliveryCashFee={self._get('deliveryCashFee')}, driverActualFreight={self._get('driverActualFreight')}") + self.log.info(f" deliveryCashFee={self._get('deliveryCashFee')}, driverActualFreight={self._get('driverActualFreight')}") except Exception as e: - print(f" [!] 补充核算字段失败: {e}") + self.log.info(f" [!] 补充核算字段失败: {e}") def set_auth(self, driver_token: str, admin_token: str): """设置授权 Token""" self.client.set_driver_auth(driver_token) self.client.set_admin_auth(admin_token) - print(f" Driver auth: {driver_token[:30]}...") - print(f" Admin auth: {admin_token[:30]}...") + self.log.info(f" Driver auth: {driver_token[:30]}...") + self.log.info(f" Admin auth: {admin_token[:30]}...") + + def close(self): + """关闭 HTTP 会话连接池(数据库连接由调用方管理)。""" + self.client.close() diff --git a/tests/apis/yun8/receipt_api.py b/tests/apis/yun8/receipt_api.py new file mode 100644 index 0000000..4658a42 --- /dev/null +++ b/tests/apis/yun8/receipt_api.py @@ -0,0 +1,142 @@ +"""回单/上报接口 Mixin — step17-20(管理端)。 + +所有方法签名/返回值/默认值与旧 yun8_sync/client.py 逐字一致, +仅将原始 requests.Session 调用替换为 BaseHttpClient(self.admin_http)。 + +Usage: + from tests.apis.yun8.receipt_api import ReceiptApi + class Yun8ApiClient(ReceiptApi, ...): ... +""" + +import re + + +class ReceiptApi: + """回单/上报接口(挂载于 Yun8ApiClient,依赖 admin_http / ac 属性)。""" + + # ================================================================== + # Step 17: 查询运单上传 → 提取 payNo + # ================================================================== + def step17_query_upload_for_payno(self, order_code: str, + page_size: int = 20, page_num: int = 1) -> dict: + """ + Postman Test Script: + var sendMsg = jsonData.data.records[0].sendMsg || ""; + var match = sendMsg.match(/【([^】]+)】/); + var result = match ? match[1] : ""; + pm.collectionVariables.set("payNo", result); + + Returns: {"payNo": str, "raw_records": list} + """ + params = { + "gmtCreatedRange[]": ["", ""], + "lastLoadingTimeRange": "", + "fillTimeRange": "", + "lastUnloadingTimeRange": "", + "signTimeRange": "", + "settleTimeRange": "", + "gmtModifiedRange": "", + "pageNum": page_num, + "pageSize": page_size, + "status": "5", # <-- 注意:status=5 + "payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""), + "sortField": "", + "sortType": "", + "gmtCreatedStart": "", + "gmtCreatedEnd": "", + "orderCode": order_code, + } + data = self.admin_http.get("/ntocc-basic-api/dytBusiness/orderDetail", params) + records = data.get("data", {}).get("records", []) + pay_no = "" + if records: + send_msg = records[0].get("sendMsg", "") + match = re.search(r"【([^】]+)】", send_msg) + if match: + pay_no = match.group(1) + + return {"payNo": pay_no, "raw_records": records} + + # ================================================================== + # Step 18: 查询打款记录 → 提取所有 code + # ================================================================== + def step18_query_remit_records(self, order_code: str, + page_size: int = 20) -> list[dict]: + """ + Postman Test Script: + pm.collectionVariables.set("remitCode", jsonData.data.records[0].code); + + 分页获取全部打款记录。Returns: [{code, ...}, ...] + """ + all_records = [] + page_num = 1 + + while True: + params = { + "pageNum": page_num, + "pageSize": page_size, + "pageForm": "arpa_2", + "orderDetailNumber": order_code, + "sortField": "", + "sortType": "", + "isPayAdvance": "", + "platPayType": "", + "gmtModifiedStart": "", + "gmtModifiedEnd": "", + } + data = self.admin_http.get("/ntocc-tms-api/remitDefinite", params) + # code 可能是 0 (成功) 或 None/null (也是成功) + resp_code = data.get("code") + if resp_code is not None and resp_code != 0: + raise RuntimeError(f"查询打款记录失败: {data.get('msg')}") + + page_data = data.get("data", {}) + records = page_data.get("records", []) + all_records.extend(records) + + total = page_data.get("total", 0) + if page_num * page_size >= total: + break + page_num += 1 + + return all_records + + # ================================================================== + # Step 19: 查询文件名前缀 → 提取 filePrefix + # ================================================================== + def step19_query_file_prefix(self, remit_code: str) -> str: + """ + Postman Test Script: + const msg = jsonData.msg || ''; + const prefix = '文件名前缀:'; + const index = msg.indexOf(prefix); + const filePrefix = index !== -1 ? msg.substring(index + prefix.length).trim() : ''; + pm.collectionVariables.set('filePrefix', filePrefix); + + Returns: filePrefix string + """ + params = {"code": remit_code} + data = self.admin_http.get( + "/ntocc-tms-api/centralizedPayment/downLoadElectronicReceipt", + params) + msg = data.get("msg", "") + prefix_marker = "文件名前缀:" + idx = msg.find(prefix_marker) + if idx != -1: + return msg[idx + len(prefix_marker):].strip() + return "" + + # ================================================================== + # Step 20: 上报运单 (reportOrderDetail) + # ================================================================== + def step20_report_order_detail(self, codes: str, report_type: int = 1) -> dict: + """ + POST /ntocc-basic-api/dytBusiness/reportOrderDetail + Content-Type: application/json;charset=UTF-8 + Body: {"codes": "...", "type": 1} + + 在打款完成后上报运单,完成整个流程闭环。 + """ + body = {"codes": codes, "type": report_type} + return self.admin_http.post_json( + "/ntocc-basic-api/dytBusiness/reportOrderDetail", body) diff --git a/tests/apis/yun8/sql_templates.py b/tests/apis/yun8/sql_templates.py new file mode 100644 index 0000000..0204ba0 --- /dev/null +++ b/tests/apis/yun8/sql_templates.py @@ -0,0 +1,130 @@ +"""yun8 全部 SQL 语句与参数化构造器。 + +所有语句统一使用 %s 参数占位(由 Db.execute 交给 pymysql 转义), +字面量列值(固定 code/时间戳/URL 等)从旧 yun8_sync 实现逐字保留。 + +Usage: + from tests.apis.yun8.sql_templates import ( + RECEIPT_FILE_INSERT_SQL, receipt_file_insert_params, + ETC_INVOICE_INSERT_SQL, ETC_PUSH_INSERT_SQL, + driver_reset_statements, post_unload_reset_update, risk_control_update, + ) +""" + +# ── 回单文件 INSERT (base_gd_mft_file) ──────────────────────────── +# 仅 file_name / front_seq_no(=payNo) 为参数;其余列值固定。 +RECEIPT_FILE_INSERT_SQL = """ +INSERT INTO `ntocc`.`base_gd_mft_file` + (`id`, `code`, `file_name`, `remark`, `is_deleted`, + `created_by`, `gmt_created`, `modified_by`, `gmt_modified`, + `modified_by_name`, `is_directory`, `parent_directory`, + `local_path`, `url_path`, `front_seq_no`) +VALUES + (NULL, '10cd8a53b921463ab8079ea61376574e', %s, + '批量写入回单编号20260608', 0, + '', '2025-12-21 16:24:11', '', '2025-12-21 16:24:11', + '', 0, '415612025121420251215274330494862543.zip', + '/data/cebBankService/tempFile/415612025121420251215274330494862543/41561202512141330494250466_1_signed.pdf', + 'https://public-yun8.eos-chongqing-1.cmecloud.cn/d83a1298991040a4a125a84acadd88b9.pdf', + %s) +""" + + +def receipt_file_insert_params(file_prefix: str, pay_no: str, + file_name_template: str) -> tuple: + """回单 INSERT 参数: (file_name, pay_no)。""" + file_name = file_name_template.format(file_prefix=file_prefix) + return (file_name, pay_no) + + +# ── ETC 发票 INSERT (base_etc_invoice) ──────────────────────────── +# 参数: code, order_code, push_order_no, order_detail_code, invoice_time, +# import_station, export_station, transaction_time, total_price, +# amount, invoice_number, en_date, ex_date, ex_time +ETC_INVOICE_INSERT_SQL = ( + "INSERT INTO `ntocc`.`base_etc_invoice` (" + "`id`, `code`, `order_code`, `push_order_no`, `order_detail_code`, " + "`invoice_time`, `sale_tax_code`, `sale_name`, `import_station`, `export_station`, " + "`transaction_time`, `total_price`, `amount`, `tax_rate`, `tax_amount`, " + "`invoice_type`, `invoice_code`, `invoice_number`, `waybill_start_time`, `waybill_end_time`, " + "`invoice_url`, `invoice_html_url`, `en_date`, `ex_date`, `ex_time`, " + "`is_valid`, `task_name`, `plate_num`, `etc_invoice_status`, `tax_deduction_status`, " + "`sign`, `is_deleted`, `created_by`, `modified_by`, `gmt_created`, `gmt_modified`) " + "VALUES (" + "NULL, %s, %s, %s, %s, " + "%s, '9141010055833160XE', '河南省视博电子股份有限公司', " + "%s, %s, " + "%s, %s, %s, 0.03, 0.03, " + "'征税发票', '*', %s, '2026-05-13 15:35:38', '2026-05-15 13:35:43', " + "'https://hdfs.txffp.com/e/1102e1en66nPPP1ChAeC161e1C6Pei16.zip?Expires=1812122633&OSSAccessKeyId=LTAIYAJbICRhyCD8&Signature=TLjlHgKtCxwWpPliPRQ0xQ6HdKY%3D', " + "'https://gateway.txffp.com/alle/common/htmlurl/getinvoice/41012526221804003093/1102e1en66nPPP1ChAeC161e1C6Pei16', " + "%s, %s, %s, " + "0, 'etc开票申请2026070604', '冀D8E016', 3, 0, " + "NULL, 0, '', '04b7e539c77d4bdbb4fe4e87eaaf41e4', '2026-07-06 09:55:16', '2026-07-06 10:03:26')" +) + + +def etc_invoice_insert_params(inv: dict, order_code: str, push_order_no: str, + order_detail_code: str) -> tuple: + """ETC 发票 INSERT 参数(inv 为 pipeline._gen_etc_invoice 的产出)。""" + return ( + inv["code"], order_code, push_order_no, order_detail_code, + inv["invoice_time"], + inv["import_station"], inv["export_station"], + inv["transaction_time"], inv["total_price"], inv["amount"], + inv["invoice_number"], + inv["en_date"], inv["ex_date"], inv["ex_time"], + ) + + +# ── ETC 推送记录 INSERT (tms_order_etc_push) ────────────────────── +# 参数: code, push_order_no, order_no, order_detail_code;其余固定。 +ETC_PUSH_INSERT_SQL = ( + "INSERT INTO `ntocc`.`tms_order_etc_push` (" + "`id`, `code`, `push_order_no`, `order_no`, `order_detail_code`, " + "`start_time`, `end_time`, `year`, `month`, `vendor`, " + "`request`, `start_request`, `end_request`, `invoice_pull_status`, `invoice_pull_time`, " + "`start_status`, `start_push_time`, `start_result`, `end_status`, `end_push_time`, `end_result`, " + "`is_deleted`, `created_by`, `modified_by`, `gmt_created`, `gmt_modified`) " + "VALUES (" + "NULL, %s, %s, %s, %s, " + "'2026-07-07 15:08:25', '2026-07-07 15:08:28', 2026, 6, 1, " + "NULL, '11111', '11111', 2, '2026-07-07 11:40:21', " + "1, '2026-07-07 15:17:02', '成功', 1, '2026-07-07 15:11:04', '成功', " + "0, NULL, NULL, '2026-07-07 11:09:17', '2026-07-07 15:15:18')" +) + + +# ── 司机流程 reset / 风控状态 UPDATE ────────────────────────────── +def driver_reset_statements(reset_code: str, order_code: str) -> list[tuple]: + """step03 后的重置序列(3 DELETE + 1 UPDATE),需在一个事务内执行。""" + return [ + ("DELETE FROM tms_order_detail WHERE code = %s", (reset_code,)), + ("DELETE FROM tms_order_detail_ext WHERE order_detail_code = %s", (reset_code,)), + ("DELETE FROM tms_order_detail_abnormal_status WHERE order_detail_code = %s", (reset_code,)), + ("UPDATE tms_order_detail SET code = %s WHERE order_code = %s", + (reset_code, order_code)), + ] + + +def post_unload_reset_update(reset_code: str, order_code: str) -> tuple: + """reset 模式卸货后的运单号/装货签收时间回写。""" + sql = ("UPDATE tms_order_detail SET code = %s, " + "fill_time = '2026-07-05 19:30:56', " + "sign_time = '2026-07-05 19:52:30' " + "WHERE order_code = %s") + return (sql, (reset_code, order_code)) + + +def risk_control_update(order_detail_code: str, status: int = 1) -> tuple: + """卸货后风控状态回写(按 order_detail_code 直更)。""" + sql = ("UPDATE tms_order_detail_ext SET risk_control_status = %s " + "WHERE order_detail_code = %s") + return (sql, (status, order_detail_code)) + + +def etc_status_update(order_code: str, status: int = 3) -> tuple: + """运单 ETC 开票状态更新 (tms_order_detail.etc_invoice_status)。""" + sql = ("UPDATE `ntocc`.`tms_order_detail` SET etc_invoice_status = %s " + "WHERE order_code = %s") + return (sql, (status, order_code)) diff --git a/tests/apis/yun8/third_party_api.py b/tests/apis/yun8/third_party_api.py new file mode 100644 index 0000000..9c3d7b3 --- /dev/null +++ b/tests/apis/yun8/third_party_api.py @@ -0,0 +1,34 @@ +"""第三方(滇运通)接口 Mixin — step21。 + +签名/返回值与旧 yun8_sync/client.py 逐字一致; +滇运通为独立 host/token 会话(self.tp_http,base_url 为滇运通域名)。 + +Usage: + from tests.apis.yun8.third_party_api import ThirdPartyApi + class Yun8ApiClient(ThirdPartyApi, ...): ... +""" + + +class ThirdPartyApi: + """滇运通接口(挂载于 Yun8ApiClient,依赖 tp_http / tp_enabled 属性)。""" + + # ================================================================== + # Step 21: 滇运通审核 (updateOrderDetailExt) + # ================================================================== + def step21_owner_review(self, order_detail_code: str) -> dict: + """ + POST /ntocc-tms-api/orderDetail/updateOrderDetailExt + 滇运通独立接口,独立 token / host。 + 仅在 config.dianyuntong.enabled=true 时执行。 + """ + if not self.tp_enabled or not self.tp_http: + raise RuntimeError("dianyuntong 未启用,请检查配置 yun8_api.dianyuntong.enabled") + params = { + "orderDetailCode": order_detail_code, + "reviewStatus": "1", + "reviewMsg": "", + "invoiceApplicationStatus": "0", + "invoiceApplicationMsg": "", + } + return self.tp_http.post( + "/ntocc-tms-api/orderDetail/updateOrderDetailExt", params=params) diff --git a/tests/config/test_config.yaml b/tests/config/test_config.yaml index d3516a8..6787fb3 100644 --- a/tests/config/test_config.yaml +++ b/tests/config/test_config.yaml @@ -145,3 +145,105 @@ database: password: "Rgxi3!kD9jjXpgzx" database: "ntocc" charset: "utf8mb4" + +# ═══════════════════════════════════════════════════════════════════ +# ── yun8 运输流程 API 造数 (tests/apis/yun8 + tests/api) ── +# 对应 Postman Collection: yun8.postman_collection.json(18 接口 + SQL) +# 数据库连接复用上方 database 段 +# ═══════════════════════════════════════════════════════════════════ +yun8_api: + base_url: "https://ybxcx.ynyun8.com:8000" + verify_ssl: false + timeout_seconds: 60 # 单请求超时(旧工具无超时,迁移后新增) + + # 通用请求头(与 Postman Collection 一致) + common_headers: + host: "ybxcx.ynyun8.com:8000" + accept-language: "zh-CN,zh;q=0.9,en;q=0.8" + accept-encoding: "gzip" + + # 司机端 Header(步骤1-5) + driver_headers: + deviceid: "A58FBAB7161EA82309491FB06CDE9687" + user-agent: "okhttp-okgo/jeasonlzy" + + # 管理端 Header(步骤6-16) + admin_headers: + deviceid: "0000000000" + secret: "GouldAMap=d58bb5f7da1aa1d6b78741ded2450f2d" + user-agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0" + accept: "application/json, text/plain, */*" + referer: "https://ybxcx.ynyun8.com:8000/admin/" + + # 授权 Token(CLI --auth-driver / --auth-admin 可覆盖) + tokens: + driver_token: "Bearer YQZOMJWOYljNZJGN0XT23kFONQJZmDRNlm20lTTYYAUY4YjhiZTj0cDlhDjw" # 司机端 Bearer token (含 Bearer 前缀) + admin_token: "Bearer Z=BZ1gdZYkDN2Ij4kzTw3EGN0GnN2TYYhJcMzQmYNUZNNEWZjNWwmTTOhQD=" # 管理端 Bearer token (含 Bearer 前缀) + + # 司机端固定参数(装货/卸货) + driver_params: + # 重量模式: "fixed"(固定值) / "random"(每次随机 1-10 小数) + weight_mode: "random" + per_weight: "10" # fixed 模式下的接单单位重量 + weight: "10" # fixed 模式下的装货/卸货重量 + # 装货/卸货经纬度(不为空时覆盖货源接口返回值) + # 历史环境坐标备查(手工切换时取消注释并覆盖上方对应键): + # load_latitude: "38.5166" # 接单+装货纬度 + # load_longitude: "102.308617" # 接单+装货经度 + # unload_latitude: "38.529333" # 卸货纬度 + # unload_longitude: "102.25395" # 卸货经度 + # load_latitude: "28.116007" # 接单+装货纬度 + # load_longitude: "112.982951" # 接单+装货经度 + # unload_latitude: "30.663179" # 卸货纬度 + # unload_longitude: "104.039285" # 卸货经度 + load_latitude: "" # 接单+装货纬度 + load_longitude: "" # 接单+装货经度 + unload_latitude: "" # 卸货纬度 + unload_longitude: "" # 卸货经度 + # 待运输单重置(清掉旧记录后重新查询,仅影响司机端流程) + reset_order_detail: false # true=step03后执行DELETE+UPDATE再重新查询 + reset_order_detail_code: "f234118ee56d4b3db4ae1eeb97b0b194" # DELETE+UPDATE SET 使用的固定 code + # 通用经纬度兜底(当上述配置和接口返回值都为空时使用) + latitude: "25.005731558883365" + longitude: "102.652599407955" + # 装货照片 URL + load_img_url: "https://ynyun8.eos-chongqing-1.cmecloud.cn/2026-06-30/88a6ff85d8ad4e26bf1571d59ae4f60c.jpg?AWSAccessKeyId=AOS5YGU0MP2Y88ELIFFH&Expires=4102329600&Signature=s68ODdXZ%2BI9s09IbaPwLy6Hefxw%3D" + # 卸货照片 URL + unload_img_url: "https://ynyun8.eos-chongqing-1.cmecloud.cn/2026-06-30/66426dc5be6242968dd9a3d0fd6e8b38.jpg?AWSAccessKeyId=AOS5YGU0MP2Y88ELIFFH&Expires=4102329600&Signature=UVKmwyFEnzA4fh%2BmnlGzmDOmuJE%3D" + + # 核算固定参数 + accounting: + branch_code: "49adb23841asw3118yunnanchenggang" + business_type_code: "1003999" + payee_code: "16d0c260f7e34967b36113c770e03486" + shipment_phone: "15198908093" + shipment_party_code: "ce8b73b38cf6450d84740cf23aa4ed52" + settlement_type: "2" + # 队长分账:payer_receiver/link_man 不为空 且 captainSplitMode=2 时触发 + payer_receiver: "" # 收款人 + payer_receiver_link_man: "" # 收款联系人 + captain_split_mode: "0" # 队长分账模式 (0=不分账, 2=分账) + execute_after_audit: true # 审核通过后是否执行分账/付款/回单等后续步骤(12-21) + + # 垫资/付款固定参数 + payment: + pay_types: "3,4,5,9,10,11" + pay_platform: "3" + sms_code: "1234" + phone: "13888888888" + pay_type: "arpa_2" + + # ETC 开票 (步骤 22) + etc_invoice: + count: 3 # 每个运单插入的发票条数; 金额合计受运费×50%约束 + + # 回单 file_name 格式(INSERT 语句本体在 tests/apis/yun8/sql_templates.py) + sql: + file_name_template: "{file_prefix}_1_signed.pdf" + + # 第三方端(滇运通审核,独立 token / host,可选执行) + dianyuntong: + enabled: false # true=执行, false=跳过 + base_url: "https://yndyt.cxtech.vip" + token: "Bearer NTmlyQYMYYEMwDTy1DDyxWjwykNZyQGO0jDOYYAMMQcNMgMN3jTy0jTYZlZh" # 独立 Bearer Token + cookie: "" # Cookie 字符串 (Admin-Token=xxx; sl-session=xxx) diff --git a/tests/core/db_helper.py b/tests/core/db_helper.py index fedd800..8ed3c48 100644 --- a/tests/core/db_helper.py +++ b/tests/core/db_helper.py @@ -10,6 +10,8 @@ Usage: import pymysql +from contextlib import contextmanager + from tests.core.config_loader import load_config @@ -73,3 +75,77 @@ def update_risk_control_status(order_code: str, status: int = 1, config: dict | ")" ) return execute_sql(sql, (status, order_code), config) + + +class Db: + """长连接数据库客户端 — 用例/pipeline 级复用。 + + 与上面的模块级函数(每语句新建连接)不同,本类在一个实例内复用单条连接: + - execute/query 前自动 ping 重连,规避长空闲连接被服务端断开 + - 事务外 execute 保持逐语句提交(与旧行为一致) + - transaction() 内的 execute 不再单独提交,由上下文统一 commit/rollback + - mogrify() 渲染参数化 SQL 为完整语句(dry-run 预览用,不执行) + + Usage: + db = Db() # 不传 config 则读 yaml database 段 + affected = db.execute("UPDATE t SET a = %s WHERE code = %s", (1, code)) + rows = db.query("SELECT * FROM t WHERE code = %s", (code,)) + with db.transaction(): + db.execute(sql1) + db.execute(sql2) + db.close() + """ + + def __init__(self, config: dict | None = None): + self._kwargs = _resolve_config(config) + self._conn: pymysql.connections.Connection | None = None + self._in_transaction = False + + def _connection(self): + if self._conn is None: + self._conn = pymysql.connect(**self._kwargs) + else: + self._conn.ping(reconnect=True) + return self._conn + + def execute(self, sql: str, params: tuple | None = None) -> int: + """执行写语句,返回影响行数。事务内调用不单独提交。""" + conn = self._connection() + with conn.cursor() as cursor: + affected = cursor.execute(sql, params) + if not self._in_transaction: + conn.commit() + return affected + + def query(self, sql: str, params: tuple | None = None) -> list[dict]: + """执行查询,返回 DictCursor 结果列表。""" + conn = self._connection() + with conn.cursor(pymysql.cursors.DictCursor) as cursor: + cursor.execute(sql, params) + return cursor.fetchall() + + @contextmanager + def transaction(self): + """事务上下文:块内所有 execute 在退出时统一提交,异常统一回滚。""" + conn = self._connection() + self._in_transaction = True + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + self._in_transaction = False + + def mogrify(self, sql: str, params: tuple | None = None) -> str: + """渲染参数化 SQL 为最终语句(含引号转义),仅预览不执行。""" + conn = self._connection() + with conn.cursor() as cursor: + return cursor.mogrify(sql, params) + + def close(self) -> None: + """关闭连接(幂等,可重复调用)。""" + if self._conn is not None: + self._conn.close() + self._conn = None diff --git a/tests/core/http_client.py b/tests/core/http_client.py new file mode 100644 index 0000000..8356df4 --- /dev/null +++ b/tests/core/http_client.py @@ -0,0 +1,118 @@ +"""统一 HTTP 客户端 — requests.Session 封装。 + +所有 API 客户端(tests/apis/)共用此基类,职责: + - 单 Session 连接复用 + 请求头管理 + - 直连服务器(禁用系统代理),避免本地代理篡改请求触发服务端 WAF 误判 + - 统一超时 / verify_ssl / 请求响应日志(INFO 摘要、DEBUG 全量) + - 统一响应处理:非 2xx 抛 HTTPError,成功返回解析后的 JSON dict + +Usage: + from tests.core.http_client import BaseHttpClient + + client = BaseHttpClient("https://api.example.com", + headers={"deviceid": "xxx"}, timeout=60) + data = client.get("/v1/resource", params={"id": 1}) + client.set_header("authorization", "Bearer xxx") +""" + +import logging +import urllib.parse + +import requests + +# INFO 日志中 path 的最大长度(避免超长 query string 刷屏) +_PATH_LOG_LIMIT = 200 + + +class BaseHttpClient: + """单 Session HTTP 客户端基类。 + + Args: + base_url: 服务根地址,如 "https://ybxcx.ynyun8.com:8000" + headers: 初始请求头(跳过空值,与旧 _init_session 行为一致) + verify_ssl: 是否校验 HTTPS 证书(测试环境自签证书默认关闭) + timeout: 单请求超时秒数;None 表示不限制(旧版行为) + name: 日志器后缀,用于区分多套会话(driver/admin/tp) + """ + + def __init__(self, base_url: str, headers: dict | None = None, + verify_ssl: bool = False, timeout: float | None = None, + name: str = "http"): + self.base_url = base_url.rstrip("/") + self.verify_ssl = verify_ssl + self.timeout = timeout + self.log = logging.getLogger(f"tests.core.http_client.{name}") + + self.session = requests.Session() + for key, value in (headers or {}).items(): + if value: + self.session.headers[key] = value + # 禁用系统代理,直连服务器(避免本地代理修改请求导致 WAF 误判 SQL 注入) + self.session.trust_env = False + self.session.proxies = {"http": None, "https": None} + + # ── 请求头管理 ──────────────────────────────────────────────── + def set_header(self, key: str, value: str) -> None: + """设置/覆盖一个请求头(允许空值,与旧版无条件覆盖行为一致)。""" + self.session.headers[key] = value + + def get_header(self, key: str, default: str = "") -> str: + """读取当前会话的一个请求头。""" + return self.session.headers.get(key, default) + + # ── 请求方法(返回解析后的 JSON dict)───────────────────────── + def get(self, path: str, params: dict | None = None) -> dict: + return self._request("GET", f"{self.base_url}{path}", params=params) + + def post(self, path: str, params: dict | None = None, + data: dict | None = None) -> dict: + return self._request("POST", f"{self.base_url}{path}", + params=params, data=data) + + def put(self, path: str, params: dict | None = None, + data: dict | None = None) -> dict: + return self._request("PUT", f"{self.base_url}{path}", + params=params, data=data) + + def post_json(self, path: str, body: dict, + params: dict | None = None) -> dict: + """POST JSON 请求体(Content-Type: application/json)。""" + return self._request("POST", f"{self.base_url}{path}", + params=params, json_body=body) + + def request_with_encoded_query(self, method: str, path: str, + params: dict) -> dict: + """手工构建 query string 的请求(GET/POST/PUT)。 + + 与 get/post/put(params=...) 的区别:query string 由 urlencode 一次性 + 编码后拼接为完整 URL 再发给 requests,避免 requests 内部再次编码导致 + URL 值中已有的 %XX(如 AWS Signature 的 %2B)被二次转义为 %25XX, + 触发服务端 WAF 的 SQL 注入误判。 + """ + qs = urllib.parse.urlencode(params, doseq=True, + quote_via=urllib.parse.quote) + full_url = f"{self.base_url}{path}?{qs}" + self.log.debug("%s 全量 URL: %s", method, full_url) + return self._request(method, full_url) + + # ── 内部实现 ───────────────────────────────────────────────── + def _request(self, method: str, url: str, params: dict | None = None, + data: dict | None = None, json_body: dict | None = None) -> dict: + display_path = url[len(self.base_url):][: _PATH_LOG_LIMIT] or "/" + self.log.info("%s %s", method, display_path) + resp = self.session.request( + method, url, + params=params, data=data, json=json_body, + verify=self.verify_ssl, timeout=self.timeout, + ) + resp.raise_for_status() + payload = resp.json() + code = payload.get("code") if isinstance(payload, dict) else None + msg = payload.get("msg", "") if isinstance(payload, dict) else "" + self.log.info(" ← %s (%.2fs) code=%s msg=%s", + resp.status_code, resp.elapsed.total_seconds(), code, msg) + return payload + + def close(self) -> None: + """关闭底层连接池。""" + self.session.close() diff --git a/yun8_sync/README.md b/yun8_sync/README.md deleted file mode 100644 index 5b80c1f..0000000 --- a/yun8_sync/README.md +++ /dev/null @@ -1,244 +0,0 @@ -# yun8 运输流程完整自动化 - -将 `yun8.postman_collection.json` 中 **"运输流程"** 全部 18 个接口 + SQL 写入实现为 Python 流水线。 - ---- - -## 流程概览 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 阶段 A: 司机端 (Step 01-05) │ -│ 查询货源 → 接单 → 查询待运输单 → 装货 → 卸货 │ -│ 变量: mainOrderCode → code / vehicleCode / orderCode │ -├─────────────────────────────────────────────────────────────────┤ -│ 阶段 B: 运费核算 (Step 06-09) │ -│ 查询运单 → 运单详情 → 核算试算 → 核算提交 │ -│ 变量: 30+ 核算字段 + ticketImageUrls │ -├─────────────────────────────────────────────────────────────────┤ -│ 阶段 C: 审核 & 付款 (Step 10-14) │ -│ 查询审核 → 审核通过 → 油卡充值 → 司机打款 → 货主打款 │ -├─────────────────────────────────────────────────────────────────┤ -│ 阶段 D: 回单文件 + SQL (Step 15-18) │ -│ 运单上传(payNo) → 打款记录 → 文件名前缀 → INSERT → 上报运单 │ -├─────────────────────────────────────────────────────────────────┤ -│ 阶段 E: ETC 开票 SQL (Step 22-24) │ -│ 写入 base_etc_invoice (多条) → 写入 tms_order_etc_push │ -│ → 更新 tms_order_detail 开票状态 │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 上下游变量传递 - -| 步骤 | 产出变量 | 消费步骤 | -|------|---------|---------| -| 01 查询货源 | `mainOrderCode`, `residueNumber` | 02, 09 | -| 03 查询待运输单 | `code`, `vehicleCode`, `orderCode` | 04-18 | -| 06 运费核算 | 30+ 字段 (branchCode, oilFee, ...) | 09, 12 | -| 07 运单详情 | `deliveryCashFee`, `driverActualFreight`, ... | 09, 13 | -| 08 核算试算 | `ticketImageUrls` | 09 | -| 15 运单上传 | `payNo` (sendMsg 正则提取) | SQL | -| 16 打款记录 | 所有 `code` | 17 | -| 17 文件名前缀 | `filePrefix` | SQL | - ---- - -## 安装 - -```bash -cd yun8_sync -pip install -r requirements.txt -``` - ---- - -## 配置 - -编辑 `config.yaml`: - -### Token(二选一) -```yaml -auth: - driver_token: "Bearer xxx" # 写在配置文件 - admin_token: "Bearer yyy" -``` -或命令行传入 `--auth-driver` / `--auth-admin`(命令行优先)。 - -### 重量模式 -```yaml -driver_params: - weight_mode: "fixed" # fixed=固定值 / random=每次随机 1.0~10.0 - per_weight: "10" # fixed 模式:接单单位重量 - weight: "10" # fixed 模式:装货/卸货重量 -``` -- **fixed**: 使用配置的固定值 -- **random**: 每次循环随机生成 1.0~10.0 小数(一位小数),`per_weight` 和 `weight` 自动保持一致 - -### 核算/付款参数 -```yaml -accounting: - branch_code: "49adb23841asw3118yunnanchenggang" - business_type_code: "1003999" - payee_code: "16d0c260f7e34967b36113c770e03486" - shipment_phone: "15198908093" - shipment_party_code: "ce8b73b38cf6450d84740cf23aa4ed52" - settlement_type: "2" - -payment: - pay_types: "3,4,5,9,10,11" - pay_platform: "3" - sms_code: "1234" - phone: "13888888888" - pay_type: "arpa_2" -``` -> 注:`branchCode` / `businessTypeCode` / `payeeCode` / `shipmentPhone` / `shipmentPartyCode` 会优先从 step06 返回值中读取,配置仅作兜底。 - -### 数据库 -```yaml -database: - host: "36.137.133.24" - port: 3306 - user: "root" - password: "Rgxi3!kD9jjXpgzx" - database: "ntocc" - charset: "utf8mb4" -``` - -### SQL 模板 -```yaml -sql: - file_name_template: "{file_prefix}_1_signed.pdf" - template: | - INSERT INTO `ntocc`.`base_gd_mft_file` (...) VALUES (...) -``` -占位符 `{file_prefix}` 和 `{pay_no}` 由流水线自动填充。 - ---- - -## 使用 - -### 命令行参数 - -| 参数 | 说明 | 默认值 | -|------|------|--------| -| `--config` | 配置文件路径 | `./config.yaml` | -| `--auth-driver` | 司机端 Bearer Token | 读取 config | -| `--auth-admin` | 管理端 Bearer Token | 读取 config | -| `--stage` | 运行阶段: `all` / `driver` / `accounting` / `payment` / `receipt` | `all` | -| `--dry-run` | 仅预览 SQL,不写库 | `False` | -| `--repeat` | 循环执行次数 | `1` | -| `--delay` | 每次循环间隔秒数 | `0` | - -### 分阶段运行 - -```bash -# 完整流程(18步) -python main.py --auth-driver "Bearer xxx" --auth-admin "Bearer yyy" - -# 仅跑司机端 (Step 01-05) -python main.py --stage driver --auth-driver "Bearer xxx" --auth-admin "Bearer yyy" - -# 仅跑运费核算 (Step 06-09) -python main.py --stage accounting --auth-driver "Bearer xxx" --auth-admin "Bearer yyy" - -# 仅跑审核付款 (Step 10-14) -python main.py --stage payment --auth-driver "Bearer xxx" --auth-admin "Bearer yyy" - -# 仅跑回单+SQL+上报 (Step 15-18) -python main.py --stage receipt --auth-driver "Bearer xxx" --auth-admin "Bearer yyy" -``` - -> 独立运行 `payment` 或 `receipt` 阶段时,会自动从 admin 端补全前置数据(code/orderCode),无需手动指定。 - -### 循环执行 - -```bash -# 完整流程循环 10 次,每次间隔 5 秒 -python main.py --repeat 10 --delay 5 - -# 仅 receipt 阶段循环 100 次 -python main.py --stage receipt --repeat 100 --delay 2 - -# 配合 random 重量模式,每次自动换新重量 -python main.py --repeat 50 --delay 3 -``` - -每次循环独立创建 Pipeline,变量上下文重置。某次失败不中断后续循环,`Ctrl+C` 随时停止。 - -### 预览模式 - -```bash -# 不写数据库,仅打印 SQL -python main.py --dry-run - -# 循环预览 -python main.py --repeat 5 --dry-run -``` - -### Token 从配置文件读取(无需命令行) - -在 `config.yaml` 中填写 `auth.driver_token` 和 `auth.admin_token`,然后: - -```bash -python main.py --repeat 100 -``` - ---- - -## 故障处理 - -| 现象 | 处理方式 | -|------|---------| -| WAF 拦截 (SQL注入误判) | 已内置:直连服务器 + 手工 query string 编码 | -| "运单正被处理中" | 已内置:自动重试 5 次,间隔递增 5~13s | -| "货运单不存在" | Token 过期或运单状态不对,检查 admin token | -| 独立阶段提示 code 为空 | 已内置:自动从 admin 端补全 | -| GBK 编码报错 | 已修复:全部输出使用纯 ASCII 字符 | - ---- - -## 项目结构 - -``` -yun8_sync/ -├── config.yaml # 配置文件 (API / Auth / 重量 / 核算 / 付款 / DB / SQL) -├── client.py # HTTP 客户端 (18 个接口 + Pre/Post 脚本逻辑) -├── pipeline.py # 流程编排 (Postman 变量上下文 + 步骤串联 + 重试) -├── db.py # 数据库操作 (INSERT) -├── main.py # CLI 入口 (分阶段 + 循环 + dry-run) -├── requirements.txt # Python 依赖 -└── README.md # 本文件 -``` - ---- - -## 接口对照表 - -| Step | 名称 | 方法 | 路径 | Auth | -|------|------|------|------|------| -| 01 | 查询货源 | GET | `/ntocc-tms-api/order/orderHallList` | Driver | -| 02 | 接单 | POST | `/ntocc-tms-api/orderDetail/receive` | Driver | -| 03 | 查询待运输单 | GET | `/ntocc-tms-api/orderDetail` | Driver | -| 04 | 装货 | PUT | `/ntocc-tms-api/orderDetail/fill` | Driver | -| 05 | 卸货 | PUT | `/ntocc-tms-api/orderDetail/uploadTicketImage` | Driver | -| 06 | 运费核算查询运单 | GET | `/ntocc-tms-api/orderDetail/orderDetailHandlePageList` | Admin | -| 07 | 查询运单详情 | POST | `/ntocc-tms-api/orderDetail/getOrderDetailExpensesParamList` | Admin | -| 08 | 核算试算 | PUT | `/ntocc-tms-api/orderDetail/calculateOrderDetail` | Admin | -| 09 | 核算提交 | PUT | `/ntocc-tms-api/orderDetail/orderHandleEditSubmitHandle` | Admin | -| 10 | 查询运单审核 | GET | `/ntocc-basic-api/dytBusiness/orderDetail` | Admin | -| 11 | 核算审核通过 | POST | `/ntocc-tms-api/orderDetail/auditOrderDetail` | Admin | -| 12 | 单油卡充值 | POST | `/ntocc-tms-api/finance/singleOil` | Admin | -| 13 | 垫资-财务打款 | POST | `/ntocc-tms-api/arpaTransfer/payToDriver` | Admin | -| 14 | 垫资-货主打款 | POST | `/ntocc-tms-api/orderDetail/applicationForPayment` | Admin | -| 15 | 查询运单上传 | GET | `/ntocc-basic-api/dytBusiness/orderDetail?status=5` | Admin | -| 16 | 查询打款记录 | GET | `/ntocc-tms-api/remitDefinite` | Admin | -| 17 | 查询文件名前缀 | GET | `/ntocc-tms-api/centralizedPayment/downLoadElectronicReceipt` | Admin | -| 18 | 上报运单 | POST | `/ntocc-basic-api/dytBusiness/reportOrderDetail` | Admin | -| — | SQL INSERT | — | `ntocc.base_gd_mft_file` | — | -| 22 | ETC 开票信息写入 | SQL INSERT | — | `ntocc.base_etc_invoice` | — | -| 23 | ETC 推送记录写入 | SQL INSERT | — | `ntocc.tms_order_etc_push` | — | -| 24 | 运单 ETC 开票状态更新 | SQL UPDATE | — | `ntocc.tms_order_detail` | — | - -> 步骤 22 每个运单插入 `etc_invoice.count` 条发票(config.yaml 配置,默认 3),每条的**发票号码**(随机 20 位数字)、**金额**(随机分配,**所有发票合计 ≤ 运费×50%**,运费优先取 `driverActualFreight`、缺失时取 `shipmentActualFreight`,超限或运费未知则跳过发票写入)、**进站/出站**(按广东→河北路段链取相邻两站)、**时间**(交易时间在运单窗口内逐条推进)各不相同;税额、开票方、发票类型、URL 等其余字段用固定值。 -> 步骤 22/23 的 `order_code`/`order_no` 取步骤 03 的 `orderCode`,`push_order_no` 为其后追加 `-1`,`order_detail_code` 取步骤 03 的 `code`,两个表的 `code` 按现有数据规则(32 位十六进制)自动生成,随 receipt 阶段执行(`--dry-run` 仅预览不写库)。 -> 步骤 23 的推送记录固定写入 `invoice_pull_status=2`、`start_status=1`、`end_status=1`、`start_result='成功'`、`end_result='成功'`;步骤 24 将步骤 03 `orderCode` 对应运单的 `etc_invoice_status` 置为 3。 diff --git a/yun8_sync/client.py b/yun8_sync/client.py deleted file mode 100644 index dca5712..0000000 --- a/yun8_sync/client.py +++ /dev/null @@ -1,965 +0,0 @@ -""" -yun8 API 客户端 -=============== -封装 Postman Collection 中运输流程的全部 16 个接口。 -Header 构造、Pre-request Script 逻辑、Test Script 变量提取全部在此实现。 - -接口列表: - [Driver 端 — 5个] - 01. 查询货源 GET /ntocc-tms-api/order/orderHallList - 02. 接单 POST /ntocc-tms-api/orderDetail/receive - 03. 查询待运输单 GET /ntocc-tms-api/orderDetail - 04. 装货 PUT /ntocc-tms-api/orderDetail/fill - 05. 卸货 PUT /ntocc-tms-api/orderDetail/uploadTicketImage - - [Admin 端 — 11个] - 06. 运费核算查询运单 GET /ntocc-tms-api/orderDetail/orderDetailHandlePageList - 07. 查询运单详情 POST /ntocc-tms-api/orderDetail/getOrderDetailExpensesParamList - 08. 核算试算 PUT /ntocc-tms-api/orderDetail/calculateOrderDetail - 09. 核算提交 PUT /ntocc-tms-api/orderDetail/orderHandleEditSubmitHandle - 10. 查询运单审核 GET /ntocc-tms-api/orderDetail?status=6&isSettle=1 - 11. 核算审核通过 POST /ntocc-tms-api/orderDetail/auditOrderDetail - 12. 垫资-财务打款 POST /ntocc-tms-api/arpaTransfer/payToDriver - 13. 垫资-货主打款 POST /ntocc-tms-api/orderDetail/applicationForPayment - 14. 查询运单上传 GET /ntocc-basic-api/dytBusiness/orderDetail?status=5 - 15. 查询打款记录 GET /ntocc-tms-api/remitDefinite - 16. 查询文件名前缀 GET /ntocc-tms-api/centralizedPayment/downLoadElectronicReceipt -""" - -import math -import re -import urllib.parse -from datetime import datetime -from typing import Optional - -import requests - - -# ============================================================ -# Pre-request Script 逻辑:生成当前时间戳 -# ============================================================ -def _current_time_str() -> str: - """Postman pre-request: 生成 'YYYY-MM-DD HH:MM:SS' 格式的当前时间""" - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - -def _debug_full_url(url: str): - """调试:打印 URL 中 ticketImageUrls / unloadImgaes 参数片段""" - import re as _re - # 找 ticketImageUrls[]= 后面的值(到下一个 & 或行尾) - m = _re.search(r'ticketImageUrls%5B%5D=([^&]{0,300})', url) - if m: - print(f" [DEBUG] ticketImageUrls[] = {m.group(1)}") - m2 = _re.search(r'unloadImgaes=([^&]{0,300})', url) - if m2: - print(f" [DEBUG] unloadImgaes = {m2.group(1)}") - - -# ============================================================ -# Yun8Client -# ============================================================ -class Yun8Client: - """封装 yun8 全部 API 请求""" - - def __init__(self, config: dict): - api = config["api"] - self.base_url = api["base_url"].rstrip("/") - self.verify_ssl = api.get("verify_ssl", False) - self.common = api.get("common_headers", {}) - - # ---- Driver Session ---- - self.driver = requests.Session() - drv = api.get("driver", {}) - self._init_session(self.driver, drv) - self.driver.headers["host"] = self.common.get("host", "") - self.driver.headers["accept-language"] = self.common.get("accept-language", "") - self.driver.headers["accept-encoding"] = self.common.get("accept-encoding", "") - - # ---- Admin Session ---- - self.admin = requests.Session() - adm = api.get("admin", {}) - self._init_session(self.admin, adm) - self.admin.headers["host"] = self.common.get("host", "") - self.admin.headers["accept-language"] = self.common.get("accept-language", "") - self.admin.headers["accept-encoding"] = self.common.get("accept-encoding", "") - - # ---- Third-party Session (滇运通,独立 token/host) ---- - tp = config.get("dianyuntong", {}) - self.tp_enabled = tp.get("enabled", False) - self.tp = None - if self.tp_enabled: - self.tp = requests.Session() - tp_headers = { - "host": "yndyt.cxtech.vip", - "authorization": tp.get("token", ""), - "deviceid": "0000000000", - "secret": "GouldAMap=d58bb5f7da1aa1d6b78741ded2450f2d", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0", - "accept": "application/json, text/plain, */*", - "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", - "origin": tp.get("base_url", ""), - "referer": f"{tp.get('base_url', '')}/admin/", - "cookie": tp.get("cookie", ""), - } - for k, v in tp_headers.items(): - if v: - self.tp.headers[k] = v - self.tp.trust_env = False - self.tp.proxies = {"http": None, "https": None} - self.tp_base = tp.get("base_url", "").rstrip("/") - - # 快捷引用 - self.dp = config.get("driver_params", {}) - self.ac = config.get("accounting", {}) - self.pm = config.get("payment", {}) - - # ------------------------------------------------------------------ - def _init_session(self, sess: requests.Session, cfg: dict): - for k, v in cfg.items(): - if v: - sess.headers[k] = v - # 禁用系统代理,直连服务器(避免本地代理修改请求导致 WAF 误判 SQL 注入) - sess.trust_env = False - sess.proxies = {"http": None, "https": None} - - def set_driver_auth(self, token: str): - self.driver.headers["authorization"] = token - - def set_admin_auth(self, token: str): - self.admin.headers["authorization"] = token - - # ------------------------------------------------------------------ - def _get(self, sess: requests.Session, path: str, params: dict = None) -> dict: - resp = sess.get(f"{self.base_url}{path}", params=params, verify=self.verify_ssl) - resp.raise_for_status() - return resp.json() - - def _post(self, sess: requests.Session, path: str, params: dict = None, data: dict = None) -> dict: - resp = sess.post(f"{self.base_url}{path}", params=params, data=data, verify=self.verify_ssl) - resp.raise_for_status() - return resp.json() - - def _put(self, sess: requests.Session, path: str, params: dict = None, data: dict = None) -> dict: - resp = sess.put(f"{self.base_url}{path}", params=params, data=data, verify=self.verify_ssl) - resp.raise_for_status() - return resp.json() - - def _put_with_query_string(self, sess: requests.Session, path: str, - params: dict) -> dict: - """ - PUT 请求,手工构建 query string。 - - 与 _put(params=...) 的区别:query string 由 urlencode 一次性编码后 - 拼接为完整 URL 再发给 requests,避免 requests 内部再次编码导致 - URL 值中已有的 %XX(如 AWS Signature 的 %2B)被二次转义为 %25XX, - 触发服务端 WAF 的 SQL 注入误判。 - """ - qs = urllib.parse.urlencode(params, doseq=True, - quote_via=urllib.parse.quote) - full_url = f"{self.base_url}{path}?{qs}" - - # DEBUG: 打印实际发出的 URL(截取 ticketImageUrls 参数附近供对比) - _debug_full_url(full_url) - - resp = sess.put(full_url, verify=self.verify_ssl) - resp.raise_for_status() - return resp.json() - - def _post_with_query_string(self, sess: requests.Session, path: str, - params: dict) -> dict: - """ - POST 请求,手工构建 query string(与 _put_with_query_string 同理)。 - 避免 requests 对逗号、URL 等字符做不必要的编码。 - """ - qs = urllib.parse.urlencode(params, doseq=True, - quote_via=urllib.parse.quote) - full_url = f"{self.base_url}{path}?{qs}" - resp = sess.post(full_url, verify=self.verify_ssl) - resp.raise_for_status() - return resp.json() - - # ================================================================== - # Step 01: 查询货源 - # ================================================================== - def step01_query_goods(self, page_size: int = 10, page_num: int = 1) -> dict: - """ - Postman Test Script: - pm.collectionVariables.set("mainOrderCode", jsonData.data.records[0].code); - pm.collectionVariables.set("residueNumber", jsonData.data.records[0].lastWeight); - - Returns: {"mainOrderCode": str, "residueNumber": str} - """ - params = { - "isFromOrderHall": "1", - "sortType": "desc", - "sortField": "gmt_created", - "pageSize": page_size, - "pageNum": page_num, - } - data = self._get(self.driver, "/ntocc-tms-api/order/orderHallList", params) - records = data.get("data", {}).get("records", []) - if not records: - raise RuntimeError("查询货源: 无记录返回") - - r = records[0] - return { - "mainOrderCode": r.get("code", ""), - "residueNumber": str(r.get("lastWeight", "")), - "shipperLatitude": str(r.get("shipperLatitude", "")), - "shipperLongitude": str(r.get("shipperLongitude", "")), - "consigneeLatitude": str(r.get("consigneeLatitude", "")), - "consigneeLongitude": str(r.get("consigneeLongitude", "")), - } - - # ================================================================== - # Step 02: 接单 - # ================================================================== - def step02_accept_order(self, main_order_code: str, residue_number: str, - message_code: str = "", - latitude: str = None, longitude: str = None) -> dict: - """ - Postman Body (urlencoded): - residueNumber / perWeight / latitude / messageCode / mainOrderCode / longitude - latitude/longitude 优先取货源接口的 shipperLat/shipperLng,没有则用 config - """ - data = { - "residueNumber": residue_number, - "perWeight": self.dp.get("per_weight", "1"), - "latitude": latitude or self.dp.get("latitude", ""), - "messageCode": message_code, - "mainOrderCode": main_order_code, - "longitude": longitude or self.dp.get("longitude", ""), - } - return self._post(self.driver, "/ntocc-tms-api/orderDetail/receive", data=data) - - # ================================================================== - # Step 03: 查询待运输单 - # ================================================================== - def step03_query_pending_transport(self, page_size: int = 10, page_num: int = 1) -> dict: - """ - Postman Test Script: - pm.collectionVariables.set("code", jsonData.data.records[0].code); - pm.collectionVariables.set("vehicleCode", jsonData.data.records[0].vehicleCode); - pm.collectionVariables.set("orderCode", jsonData.data.records[0].orderCode); - - Returns: {"code": str, "vehicleCode": str, "orderCode": str} - """ - params = { - "assignmentStatus": "waitTrans", - "placeOfReceipt": "", - "gmtCreatedEnd": "", - "gmtCreatedStart": "", - "pageSize": page_size, - "placeOfShipment": "", - "pageNum": page_num, - "shipmentName": "", - "status": "1", - "vehicleLicense": "", - } - data = self._get(self.driver, "/ntocc-tms-api/orderDetail", params) - records = data.get("data", {}).get("records", []) - if not records: - raise RuntimeError("查询待运输单: 无记录返回") - - r = records[0] - return { - "code": r.get("code", ""), - "vehicleCode": r.get("vehicleCode", ""), - "orderCode": r.get("orderCode", ""), - } - - # ================================================================== - # Step 04: 装货 - # ================================================================== - def step04_load_goods(self, code: str, vehicle_code: str, - img_url: str = "", quantity: str = "", - weight: str = None, - latitude: str = None, longitude: str = None) -> dict: - """ - Postman Pre-request Script: 生成 currentTime - Body: imgUrl / orderType / quantity / code / latitude / weight / vehicleCode / time / longitude - latitude/longitude 优先取货源接口的 shipperLat/shipperLng - """ - if weight is None: - weight = self.dp.get("weight", "1") - body = { - "imgUrl": img_url or self.dp.get("load_img_url", ""), - "orderType": "1", - "quantity": quantity, - "code": code, - "latitude": latitude or self.dp.get("latitude", ""), - "weight": weight, - "vehicleCode": vehicle_code, - "time": _current_time_str(), - "longitude": longitude or self.dp.get("longitude", ""), - } - return self._put(self.driver, "/ntocc-tms-api/orderDetail/fill", data=body) - - # ================================================================== - # Step 05: 卸货 - # ================================================================== - def step05_unload_goods(self, code: str, img_url: str = "", - quantity: str = "", weight: str = None, - latitude: str = None, longitude: str = None) -> dict: - """ - Postman Pre-request Script: 生成 currentTime - Body: imgUrl / code / quantity / latitude / weight / time / longitude - latitude/longitude 优先取货源接口的 consigneeLat/consigneeLng - """ - if weight is None: - weight = self.dp.get("weight", "1") - body = { - "imgUrl": img_url or self.dp.get("unload_img_url", ""), - "code": code, - "quantity": quantity, - "latitude": latitude or self.dp.get("latitude", ""), - "weight": weight, - "time": _current_time_str(), - "longitude": longitude or self.dp.get("longitude", ""), - } - return self._put(self.driver, "/ntocc-tms-api/orderDetail/uploadTicketImage", data=body) - - # ================================================================== - # Step 06: 运费核算查询运单 - # ================================================================== - def step06_query_for_accounting(self, order_code: str, - page_size: int = 20, page_num: int = 1) -> dict: - """ - Postman Test Script: 设置 30+ 个 collection variables - 从 data.records[0] 中提取所有字段 - - Returns: dict with all fields from records[0] - """ - params = { - "status": "3", - "lastLoadingTimeRange": "", - "fillTimeRange": "", - "lastUnloadingTimeRange": "", - "signTimeRange": "", - "gmtModifiedRange": "", - "rangeValue_start_1": "", - "rangeValue_end_1": "", - "rangeValue_start_2": "", - "rangeValue_end_2": "", - "rangeValue_start_3": "", - "rangeValue_end_3": "", - "pageNum": page_num, - "pageSize": page_size, - "gmtCreatedStart": "", - "gmtCreatedEnd": "", - "lastUnloadingTimeStart": "", - "lastUnloadingTimeEnd": "", - "lastLoadingTimeStart": "", - "lastLoadingTimeEnd": "", - "gmtModifiedStart": "", - "gmtModifiedEnd": "", - "fillTimeStart": "", - "fillTimeEnd": "", - "signTimeStart": "", - "signTimeEnd": "", - "ordCreatedByName": "", - "isUnloadingConfirm": "1", - "isCannotAccounting": "0", - "riskControlStatus": "", - "riskControlReason": "", - "sortField": "", - "sortType": "", - "orderCode": order_code, - } - data = self._get(self.admin, "/ntocc-tms-api/orderDetail/orderDetailHandlePageList", params) - records = data.get("data", {}).get("records", []) - if not records: - raise RuntimeError("运费核算查询运单: 无记录返回") - return records[0] - - # ================================================================== - # Step 07: 查询运单详情 - # ================================================================== - def step07_query_order_expenses(self, code: str) -> dict: - """ - Postman Test Script: - pm.collectionVariables.set("canWastage", ...); - pm.collectionVariables.set("deliveryCashFee", ...); - pm.collectionVariables.set("deliveryFeeDeserved", ...); - pm.collectionVariables.set("deliveryFeePractical", ...); - pm.collectionVariables.set("driverActualCash", ...); - pm.collectionVariables.set("driverActualFreight", ...); - - Returns: {canWastage, deliveryCashFee, deliveryFeeDeserved, - deliveryFeePractical, driverActualCash, driverActualFreight} - """ - params = {"codes": code} - data = self._post(self.admin, - "/ntocc-tms-api/orderDetail/getOrderDetailExpensesParamList", - params=params) - items = data.get("data", []) - if not items: - raise RuntimeError("查询运单详情: 无数据返回") - r = items[0] - return { - "canWastage": str(r.get("canWastage", "")), - "deliveryCashFee": str(r.get("deliveryCashFee", "")), - "deliveryFeeDeserved": str(r.get("deliveryFeeDeserved", "")), - "deliveryFeePractical": str(r.get("deliveryFeePractical", "")), - "driverActualCash": str(r.get("driverActualCash", "")), - "driverActualFreight": str(r.get("driverActualFreight", "")), - } - - # ================================================================== - # Step 08: 核算试算 (calculateOrderDetail) - # ================================================================== - def step08_calculate_order_detail(self, code: str) -> dict: - """ - Postman Test Script: - pm.collectionVariables.set("ticketImageUrls[]", jsonData.data.ticketImageUrls); - - Returns: dict with ticketImageUrls + 其他 step08 返回的核算数据 (taxPrice 等) - """ - params = {"code": code, "isFirst": "1"} - data = self._put(self.admin, "/ntocc-tms-api/orderDetail/calculateOrderDetail", params=params) - inner = data.get("data", {}) or {} - ticket_urls = inner.get("ticketImageUrls", "") - if isinstance(ticket_urls, list): - ticket_urls = ticket_urls[0] if ticket_urls else "" - # 返回所有有用字段,供 pipeline 存入 vars - result = {"ticketImageUrls": str(ticket_urls)} - for key in ("taxPrice", "taxFee", "taxPayFee", "taxPoint", - "price", "realPrice", "shipmentActualCash", - "shipmentActualFreight"): - if key in inner: - result[key] = str(inner[key]) - return result - - # ================================================================== - # Step 09: 核算提交 - # ================================================================== - def step09_submit_accounting(self, accounting_vars: dict) -> dict: - """ - 提交核算,参数来自前面步骤累积的所有变量。 - accounting_vars 应包含 steps 03/06/07/08 的全部字段。 - 优先从 step06 返回值取值,取不到再用 config.yaml 默认值。 - """ - av = accounting_vars - # 便捷取值:vars 优先 → config 兜底 - def _v(key, cfg_key, default=""): - return av.get(key) or self.ac.get(cfg_key, default) - - # Build the massive query string - params = { - "abnormalFee": "0", - "accountCheckAuthStatus": "0", - "accountCheckInfoAuthStatus": "0", - "accountCheckUpAuthStatus": "0", - "accountStatus": "0", - "allTrans": "0", - "allotAmount": "0", - "allotOil": "0", - "allotTaxAmount": "0", - "appSendLoadLocation": "0", - "appSendUnloadLocation": "0", - "applyStatus": "0", - "branchCode": _v("branchCode", "branch_code"), - "businessTypeCode": _v("businessTypeCode", "business_type_code"), - "canWastage": av.get("canWastage", ""), - "cancelTrans": "0", - "cancelType": "0", - "captainSplitCurrentVersion": "0", - "captainSplitMode": self.ac.get("captain_split_mode") or av.get("captainSplitMode", ""), - "cashDeposit": "0", - "coalName": "", - "coalPrice": av.get("coalPrice", ""), - "coalType": av.get("coalType", ""), - "coalTypeName": av.get("coalTypeName", ""), - "coalUnit": "0", - "coalUnitName": av.get("coalUnitName", ""), - "code": av.get("code", ""), - "contractStatus": "1", - "createdBy": av.get("createdBy", ""), - "createdByName": av.get("createdByName", ""), - "deleted": "0", - "deliveryCashFee": av.get("deliveryCashFee", ""), - "deliveryFeeDeserved": av.get("deliveryFeeDeserved", ""), - "deliveryFeePractical": av.get("deliveryFeePractical", ""), - "deliveryOilFee": "0", - "distance": av.get("distance", ""), - "driverActualCash": av.get("driverActualCash", ""), - "driverActualFreight": av.get("driverActualFreight", ""), - "driverCode": av.get("driverCode", ""), - "driverName": av.get("driverName", ""), - "driverPhone": av.get("driverPhone", ""), - "energyCardType": av.get("energyCardType", ""), - "etcType": "0", - "extraFee": "0", - "fillTime": av.get("fillTime", ""), - "finishedTrans": "0", - "gmtCreated": av.get("gmtCreated", ""), - "gmtModified": av.get("gmtModified", ""), - "id": av.get("id", ""), - "inFee": "0", - "infoFee": "0", - "insuranceAmount": "0", - "insuranceFee": "0", - "invoiceIsAll": "0", - "invoiceStatus": "0", - "invoiceStatusCaptain": "0", - "invoiceStatusDriver": "0", - "isAbnormal": "0", - "isCancel": "0", - "isCaptainOrder": "0", - "isCash": "0", - "isCuikuan": "0", - "isDriverComment": "0", - "isEqualDriver": "1", - "isEqualTeam": "1", - "isEqualVehicle": "1", - "isEtcHistory": "0", - "isFill": "1", - "isImport": "0", - "isInsure": "0", - "isLoadingConfirm": "1", - "isLoadingConfirmDate": av.get("isLoadingConfirmDate", ""), - "isOriginalOrder": "1", - "isPaperReturns": "0", - "isReceiptReturn": "0", - "isReportOrder": "0", - "isSend": "0", - "isSendDishonestyVehicle": "0", - "isSendFund": "0", - "isSendFundZhongLian": "0", - "isSendLoadLocation": "0", - "isSendTax": "0", - "isSendUnloadLocation": "0", - "isSendZhongLian": "0", - "isSettle": "0", - "isShipmentComment": "0", - "isSign": "1", - "isSplitOrder": "0", - "isSupplementary": "0", - "isTransfer": "0", - "isTrunkName": av.get("isTrunkName", ""), - "isUnloadingComfirmDate": av.get("isUnloadingComfirmDate", ""), - "isUnloadingConfirm": "1", - "isWsShare": "0", - "loadImgUrls[]": av.get("ticketImageUrls", ""), - "loadLatitude": av.get("loadLatitude", ""), - "loadLongitude": av.get("loadLongitude", ""), - "loadPlace": av.get("loadPlace", ""), - "loadQuantity": av.get("loadQuantity", ""), - "loadWeight": av.get("loadWeight", "1"), - "mainOrderCode": av.get("mainOrderCode", ""), - "modifiedBy": av.get("modifiedBy", ""), - "modifiedByName": av.get("modifiedByName", ""), - "monthlySettlementStatus": "0", - "oilFee": av.get("oilFee", ""), - "onTrans": "0", - "orderCode": av.get("orderCode", ""), - "payAmount": "0", - "payOil": "0", - "payStatus": "0", - "payStep": "10", - "payTaxAmount": "0", - "payTotalAmount": av.get("payTotalAmount", ""), - "payType": "0", - "payeeCode": _v("createdBy", "payee_code"), # step06 createdBy = payeeCode - "payeeName": av.get("payeeName", ""), - "payerReceiver": self.ac.get("payer_receiver", ""), - "payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""), - "platformPrepayment": "0", - "prepaidStatus": "0", - "price": av.get("price", ""), - "proofAmount": "0", - "proofStatus": "0", - "realPrice": av.get("realPrice", ""), - "receiptNum": "", - "receiptPayAmount": "0", - "refuelProportion": "0", - "reportMark": "0", - "reportStage": "0", - "settlementCash": "0", - "settlementOil": "0", - "settlementType": self.ac.get("settlement_type", "2"), - "settlementWeight": av.get("settlementWeight", "1"), - "shipmentActualCash": av.get("shipmentActualCash", ""), - "shipmentActualFreight": av.get("shipmentActualFreight", ""), - "shipmentCardScale": "10", - "shipmentDeliveryFeePractical": av.get("shipmentDeliveryFeePractical", ""), - "shipmentName": av.get("shipmentName", ""), - "shipmentPayStatus": "0", - "shipmentPhone": _v("shipmentPhone", "shipment_phone"), - "shipperDeliveryFee": av.get("shipperDeliveryFee", ""), - "shippingNoteNumber": av.get("shippingNoteNumber", ""), - "signTime": av.get("signTime", ""), - "status": "3", - "takeLatitude": av.get("takeLatitude", ""), - "takeLongitude": av.get("takeLongitude", ""), - "takeOrderType": "2", - "takePlace": av.get("takePlace", ""), - "taxFee": av.get("taxFee", ""), - "taxPayFee": av.get("taxPayFee", ""), - "taxPrice": av.get("taxPrice", ""), - "ticketImageUrls[]": av.get("ticketImageUrls", ""), - "txStatus": "0", - "unloadLatitude": self.dp.get("latitude", ""), - "unloadLongitude": self.dp.get("longitude", ""), - "unloadPlace": av.get("unloadPlace", ""), - "unloadQuantity": av.get("unloadQuantity", ""), - "unloadWeight": av.get("unloadWeight", "1"), - "vehicleCode": av.get("vehicleCode", ""), - "vehicleLicense": av.get("vehicleLicense", ""), - "waitCar": "0", - "waitCheckTrans": "0", - "waitDriverCheckTrans": "0", - "waitPay": "0", - "waitSettle": "0", - "waitTrans": "0", - "wastage": "0", - "weight": av.get("weight", "1"), - "whetherOverload": "0", - "wsShareRatio": "0", - "deductionFee": "0", - "driverErase": "0", - "driverFixedPay": "0", - "driverSettlementWeight": av.get("driverSettlementWeight", "1"), - "electricityCardScale": "10", - "gasCardScale": "10", - "isTrunk": "0", - "limitWastageAmount": "500", - "limitWastageType": "1", - "shipmentErase": "0", - "shipmentPartyCode": _v("shipmentPartyCode", "shipment_party_code"), - "shipmentSettlementWeight": av.get("shipmentSettlementWeight", "1"), - "shipperFixedPay": "0", - "taxPoint": av.get("taxPoint", ""), - "taxPriceFixedPay": "0", - "orderExtraFeeListStr": "[]", - "BranchCode": _v("branchCode", "branch_code"), - "unloadImgaes": av.get("ticketImageUrls", ""), - "truckLoadingImgaes": av.get("ticketImageUrls", ""), - "IsTaxPoint": "0", - "RealPrice": av.get("realPrice", ""), - "gmtCreated2": av.get("gmtCreated", ""), - "gmtModified2": av.get("gmtModified", ""), - } - return self._put_with_query_string(self.admin, "/ntocc-tms-api/orderDetail/orderHandleEditSubmitHandle", params=params) - - # ================================================================== - # Step 10: 查询运单审核 - # ================================================================== - def step10_query_audit(self, order_code: str, - page_size: int = 20, page_num: int = 1) -> dict: - """ - GET /ntocc-tms-api/orderDetail (status=6 已结算, isSettle=1) - Admin 端查询已结算运单用于审核。 - """ - params = { - "isAbnormalHelp": "", - "gmtCreatedRange": "", - "lastLoadingTimeRange": "", - "fillTimeRange": "", - "lastUnloadingTimeRange": "", - "signTimeRange": "", - "settleTimeRange": "", - "gmtModifiedRange": "", - "pageNum": page_num, - "pageSize": page_size, - "isSettle": "1", - "status": "6", - "payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""), - "sortField": "", - "sortType": "", - "orderCode": order_code, - } - return self._get(self.admin, "/ntocc-tms-api/orderDetail", params) - - # ================================================================== - # Step 11: 核算审核通过 - # ================================================================== - def step11_approve_audit(self, code: str) -> dict: - """ - POST /ntocc-tms-api/orderDetail/auditOrderDetail?orderDetailCodes={code}&isPass=true - """ - params = {"orderDetailCodes": code, "isPass": "true"} - return self._post_with_query_string(self.admin, "/ntocc-tms-api/orderDetail/auditOrderDetail", params=params) - - # ================================================================== - # Step 12: 队长分账预览 (captainSplitAdjustPreview) - # ================================================================== - def step12_captain_split_preview(self, order_detail_code: str) -> dict: - """ - GET /ntocc-tms-api/orderDetail/captainSplitAdjustPreview/{code} - 仅在 payerReceiver 不为空且 captainSplitMode=2 时调用。 - """ - path = f"/ntocc-tms-api/orderDetail/captainSplitAdjustPreview/{order_detail_code}" - return self._get(self.admin, path) - - # Step 13: 队长分账调整 (adjustCaptainSplit) - # ================================================================== - def step13_adjust_captain_split(self, order_detail_code: str, - latest_driver_amount: float, - delivery_oil_fee: float) -> dict: - """ - POST /ntocc-tms-api/orderDetail/adjustCaptainSplit - Content-Type: application/json;charset=UTF-8 - - driverAmount = ceil(latestDriverAmount * 0.8 * 100) / 100 - captainAmount = latestDriverAmount - driverAmount - oilFee = deliveryOilFee - """ - driver_amount = math.ceil(latest_driver_amount * 0.8 * 100) / 100.0 - captain_amount = round(latest_driver_amount - driver_amount, 2) - body = { - "orderDetailCode": order_detail_code, - "driverAmount": str(driver_amount), - "captainAmount": str(captain_amount), - "oilFee": delivery_oil_fee, - } - resp = self.admin.post( - f"{self.base_url}/ntocc-tms-api/orderDetail/adjustCaptainSplit", - json=body, - verify=self.verify_ssl, - ) - resp.raise_for_status() - return resp.json() - # Step 14: 单油卡充值 (singleOil) - # ================================================================== - def step14_single_oil(self, order_detail_code: str, amount: str = "0.03", - pay_type: str = "1", category: str = "1") -> dict: - """ - POST /ntocc-tms-api/finance/singleOil - Query params: payType, amount, orderDetailCode, category, isAdvancePayment - No body (content-length: 0) - - 在垫资-财务打款之前调用,处理油卡充值。 - """ - params = { - "payType": pay_type, - "amount": amount, - "orderDetailCode": order_detail_code, - "category": category, - "isAdvancePayment": "1", - } - return self._post_with_query_string( - self.admin, - "/ntocc-tms-api/finance/singleOil", - params=params, - ) - - # ================================================================== - # Step 15: 垫资-财务打款 - # ================================================================== - def step15_pay_to_driver(self, code: str, amount: str = None, - delivery_cash_fee: str = None, - driver_actual_freight: str = None, - plan_driver_payout: str = "0", - plan_captain_payout: str = "0") -> dict: - """ - POST /ntocc-tms-api/arpaTransfer/payToDriver - amount / deliveryCashFee / driverActualFreight 由 pipeline 从 step07 传入 - planDriverPayout / planCaptainPayout 来自队长分账步骤,无分账时为 "0" - """ - if amount is None: - amount = "0.27" - if delivery_cash_fee is None: - delivery_cash_fee = "0.27" - if driver_actual_freight is None: - driver_actual_freight = "0.3" - params = { - "payTypes": self.pm.get("pay_types", "3,4,5,9,10,11"), - "amount": amount, - "orderDetailCode": code, - "infoFee": "0", - "pageNum": "1", - "pageSize": "20", - "totaltotal": "0", - "payerRole": "0", - "isAdvancePayment": "1", - "planDriverPayout": plan_driver_payout, - "planCaptainPayout": plan_captain_payout, - "allotAmount": "0", - "deliveryCashFee": delivery_cash_fee, - "driverActualFreight": driver_actual_freight, - "total": "0", - "payPlatform": self.pm.get("pay_platform", "3"), - "smsCode": self.pm.get("sms_code", "1234"), - "phone": self.pm.get("phone", "13888888888"), - } - return self._post_with_query_string(self.admin, "/ntocc-tms-api/arpaTransfer/payToDriver", params=params) - - # ================================================================== - # Step 16: 垫资-货主打款 (申请付款) - # ================================================================== - def step16_apply_for_payment(self, code: str, total_amount: str = None) -> dict: - """ - POST /ntocc-tms-api/orderDetail/applicationForPayment - totalAmount 应从 pipeline 传入 (step06 的 shipmentActualCash) - """ - if total_amount is None: - total_amount = "0.32" - params = { - "payType": self.pm.get("pay_type", "arpa_2"), - "totalAmount": total_amount, - "codes": code, - "payPlatform": self.pm.get("pay_platform", "3"), - "isAdvancePayment": "1", - } - return self._post_with_query_string(self.admin, "/ntocc-tms-api/orderDetail/applicationForPayment", params=params) - - # ================================================================== - # Step 17: 查询运单上传 → 提取 payNo - # ================================================================== - def step17_query_upload_for_payno(self, order_code: str, - page_size: int = 20, page_num: int = 1) -> dict: - """ - Postman Test Script: - var sendMsg = jsonData.data.records[0].sendMsg || ""; - var match = sendMsg.match(/【([^】]+)】/); - var result = match ? match[1] : ""; - pm.collectionVariables.set("payNo", result); - - Returns: {"payNo": str, "raw_records": list} - """ - params = { - "gmtCreatedRange[]": ["", ""], - "lastLoadingTimeRange": "", - "fillTimeRange": "", - "lastUnloadingTimeRange": "", - "signTimeRange": "", - "settleTimeRange": "", - "gmtModifiedRange": "", - "pageNum": page_num, - "pageSize": page_size, - "status": "5", # <-- 注意:status=5 - "payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""), - "sortField": "", - "sortType": "", - "gmtCreatedStart": "", - "gmtCreatedEnd": "", - "orderCode": order_code, - } - data = self._get(self.admin, "/ntocc-basic-api/dytBusiness/orderDetail", params) - records = data.get("data", {}).get("records", []) - pay_no = "" - if records: - send_msg = records[0].get("sendMsg", "") - match = re.search(r"【([^】]+)】", send_msg) - if match: - pay_no = match.group(1) - - return {"payNo": pay_no, "raw_records": records} - - # ================================================================== - # Step 18: 查询打款记录 → 提取所有 code - # ================================================================== - def step18_query_remit_records(self, order_code: str, - page_size: int = 20) -> list[dict]: - """ - Postman Test Script: - pm.collectionVariables.set("remitCode", jsonData.data.records[0].code); - - 分页获取全部打款记录。Returns: [{code, ...}, ...] - """ - all_records = [] - page_num = 1 - - while True: - params = { - "pageNum": page_num, - "pageSize": page_size, - "pageForm": "arpa_2", - "orderDetailNumber": order_code, - "sortField": "", - "sortType": "", - "isPayAdvance": "", - "platPayType": "", - "gmtModifiedStart": "", - "gmtModifiedEnd": "", - } - data = self._get(self.admin, "/ntocc-tms-api/remitDefinite", params) - # code 可能是 0 (成功) 或 None/null (也是成功) - resp_code = data.get("code") - if resp_code is not None and resp_code != 0: - raise RuntimeError(f"查询打款记录失败: {data.get('msg')}") - - page_data = data.get("data", {}) - records = page_data.get("records", []) - all_records.extend(records) - - total = page_data.get("total", 0) - if page_num * page_size >= total: - break - page_num += 1 - - return all_records - - # ================================================================== - # Step 19: 查询文件名前缀 → 提取 filePrefix - # ================================================================== - def step19_query_file_prefix(self, remit_code: str) -> str: - """ - Postman Test Script: - const msg = jsonData.msg || ''; - const prefix = '文件名前缀:'; - const index = msg.indexOf(prefix); - const filePrefix = index !== -1 ? msg.substring(index + prefix.length).trim() : ''; - pm.collectionVariables.set('filePrefix', filePrefix); - - Returns: filePrefix string - """ - params = {"code": remit_code} - data = self._get(self.admin, - "/ntocc-tms-api/centralizedPayment/downLoadElectronicReceipt", - params) - msg = data.get("msg", "") - prefix_marker = "文件名前缀:" - idx = msg.find(prefix_marker) - if idx != -1: - return msg[idx + len(prefix_marker):].strip() - return "" - - # ================================================================== - # Step 20: 上报运单 (reportOrderDetail) - # ================================================================== - def step20_report_order_detail(self, codes: str, report_type: int = 1) -> dict: - """ - POST /ntocc-basic-api/dytBusiness/reportOrderDetail - Content-Type: application/json;charset=UTF-8 - Body: {"codes": "...", "type": 1} - - 在打款完成后上报运单,完成整个流程闭环。 - """ - body = {"codes": codes, "type": report_type} - resp = self.admin.post( - f"{self.base_url}/ntocc-basic-api/dytBusiness/reportOrderDetail", - json=body, - verify=self.verify_ssl, - ) - resp.raise_for_status() - return resp.json() - - # ================================================================== - # Step 21: 滇运通审核 (updateOrderDetailExt) - # ================================================================== - def step21_owner_review(self, order_detail_code: str) -> dict: - """ - POST /ntocc-tms-api/orderDetail/updateOrderDetailExt - 滇运通独立接口,独立 token / host。 - 仅在 config.dianyuntong.enabled=true 时执行。 - """ - if not self.tp_enabled or not self.tp: - raise RuntimeError("dianyuntong 未启用,请检查 config.yaml") - params = { - "orderDetailCode": order_detail_code, - "reviewStatus": "1", - "reviewMsg": "", - "invoiceApplicationStatus": "0", - "invoiceApplicationMsg": "", - } - resp = self.tp.post( - f"{self.tp_base}/ntocc-tms-api/orderDetail/updateOrderDetailExt", - params=params, - verify=self.verify_ssl, - ) - resp.raise_for_status() - return resp.json() - - # ================================================================== diff --git a/yun8_sync/config.yaml b/yun8_sync/config.yaml deleted file mode 100644 index 2675ddc..0000000 --- a/yun8_sync/config.yaml +++ /dev/null @@ -1,131 +0,0 @@ -# ============================================================ -# yun8 运输流程完整自动化 — 配置文件 -# 对应 Postman Collection: yun8.postman_collection.json -# ============================================================ - -# ----- API 配置 ----- -api: - base_url: "https://ybxcx.ynyun8.com:8000" - verify_ssl: false - - # 通用请求头(与 Postman Collection 一致) - common_headers: - host: "ybxcx.ynyun8.com:8000" - accept-language: "zh-CN,zh;q=0.9,en;q=0.8" - accept-encoding: "gzip" - - # 司机端 Header(步骤1-5) - driver: - deviceid: "A58FBAB7161EA82309491FB06CDE9687" - user-agent: "okhttp-okgo/jeasonlzy" - - # 管理端 Header(步骤6-16) - admin: - deviceid: "0000000000" - secret: "GouldAMap=d58bb5f7da1aa1d6b78741ded2450f2d" - user-agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0" - accept: "application/json, text/plain, */*" - referer: "https://ybxcx.ynyun8.com:8000/admin/" - -# ----- 授权 Token ----- -# 通过命令行 --auth-driver / --auth-admin 传入,或在此写默认值 -auth: - # driver_token: "Bearer NRWt5TGNYgZOMYWimJ21xTzYYgEZMYD45Dz3lZEOYcTOZJG4jTT55QMZYFDj" # 司机端 Bearer token (含 Bearer 前缀) - driver_token: "Bearer YQZOMJWOYljNZJGN0XT23kFONQJZmDRNlm20lTTYYAUY4YjhiZTj0cDlhDjw" # 司机端 Bearer token (含 Bearer 前缀) - admin_token: "Bearer Z=BZ1gdZYkDN2Ij4kzTw3EGN0GnN2TYYhJcMzQmYNUZNNEWZjNWwmTTOhQD=" # 管理端 Bearer token (含 Bearer 前缀) - -# ----- 司机端固定参数(装货/卸货) ----- -driver_params: - # 重量模式: "fixed"(固定值) / "random"(每次随机 1-10 小数) - weight_mode: "random" - per_weight: "10" # fixed 模式下的接单单位重量 - weight: "10" # fixed 模式下的装货/卸货重量 - # 装货/卸货经纬度(不为空时覆盖货源接口返回值) - # load_latitude: "38.5166" # 接单+装货纬度 - # load_longitude: "102.308617" # 接单+装货经度 - # unload_latitude: "38.529333" # 卸货纬度 - # unload_longitude: "102.25395" # 卸货经度 - # load_latitude: "28.116007" # 接单+装货纬度 - # load_longitude: "112.982951" # 接单+装货经度 - # unload_latitude: "30.663179" # 卸货纬度 - # unload_longitude: "104.039285" # 卸货经度 - load_latitude: "" # 接单+装货纬度 - load_longitude: "" # 接单+装货经度 - unload_latitude: "" # 卸货纬度 - unload_longitude: "" # 卸货经度 - # 待运输单重置(清掉旧记录后重新查询,仅影响司机端流程) - reset_order_detail: false # true=step03后执行DELETE+UPDATE再重新查询 - reset_order_detail_code: "f234118ee56d4b3db4ae1eeb97b0b194" # DELETE+UPDATE SET 使用的固定 code - # 通用经纬度兜底(当上述配置和接口返回值都为空时使用) - latitude: "25.005731558883365" - longitude: "102.652599407955" - # 装货照片 URL - load_img_url: "https://ynyun8.eos-chongqing-1.cmecloud.cn/2026-06-30/88a6ff85d8ad4e26bf1571d59ae4f60c.jpg?AWSAccessKeyId=AOS5YGU0MP2Y88ELIFFH&Expires=4102329600&Signature=s68ODdXZ%2BI9s09IbaPwLy6Hefxw%3D" - # 卸货照片 URL - unload_img_url: "https://ynyun8.eos-chongqing-1.cmecloud.cn/2026-06-30/66426dc5be6242968dd9a3d0fd6e8b38.jpg?AWSAccessKeyId=AOS5YGU0MP2Y88ELIFFH&Expires=4102329600&Signature=UVKmwyFEnzA4fh%2BmnlGzmDOmuJE%3D" - -# ----- 核算固定参数 ----- -accounting: - branch_code: "49adb23841asw3118yunnanchenggang" - business_type_code: "1003999" - payee_code: "16d0c260f7e34967b36113c770e03486" - shipment_phone: "15198908093" - shipment_party_code: "ce8b73b38cf6450d84740cf23aa4ed52" - settlement_type: "2" - # 队长分账:payer_receiver/link_man 不为空 且 captainSplitMode=2 时触发 - # payer_receiver: "bd759732e4ac442b8fabbf65a405dae1" # 收款人 - # payer_receiver_link_man: "车队一" # 收款联系人 - # captain_split_mode: "2" # 队长分账模式 (0=不分账, 2=分账) - # execute_after_audit: true # 审核通过后是否执行分账/付款/回单等后续步骤(12-21) - payer_receiver: "" # 收款人 - payer_receiver_link_man: "" # 收款联系人 - captain_split_mode: "0" # 队长分账模式 (0=不分账, 2=分账) - execute_after_audit: true # 审核通过后是否执行分账/付款/回单等后续步骤(12-21) - -# ----- 垫资/付款固定参数 ----- -payment: - pay_types: "3,4,5,9,10,11" - pay_platform: "3" - sms_code: "1234" - phone: "13888888888" - pay_type: "arpa_2" - -# ----- ETC 开票 (步骤 22) ----- -etc_invoice: - count: 3 # 每个运单插入的发票条数; 每条发票号码/金额/进出站/时间自动变化, 其余字段用固定值; 金额合计受运费×50%约束 - -# ----- 数据库配置 ----- -database: - host: "36.137.133.24" - port: 3306 - user: "ntocc" - password: "Rgxi3!kD9jjXpgzx" - database: "ntocc" - charset: "utf8mb4" - -# ----- SQL 模板 ----- -sql: - # file_name 格式: {file_prefix}_1_signed.pdf - file_name_template: "{file_prefix}_1_signed.pdf" - # INSERT 模板, 占位符 {file_name} / {pay_no} - template: | - INSERT INTO `ntocc`.`base_gd_mft_file` - (`id`, `code`, `file_name`, `remark`, `is_deleted`, - `created_by`, `gmt_created`, `modified_by`, `gmt_modified`, - `modified_by_name`, `is_directory`, `parent_directory`, - `local_path`, `url_path`, `front_seq_no`) - VALUES - (NULL, '10cd8a53b921463ab8079ea61376574e', '{file_name}', - '批量写入回单编号20260608', 0, - '', '2025-12-21 16:24:11', '', '2025-12-21 16:24:11', - '', 0, '415612025121420251215274330494862543.zip', - '/data/cebBankService/tempFile/415612025121420251215274330494862543/41561202512141330494250466_1_signed.pdf', - 'https://public-yun8.eos-chongqing-1.cmecloud.cn/d83a1298991040a4a125a84acadd88b9.pdf', - '{pay_no}') - -# ----- 第三方端(滇运通审核,独立 token / host,可选执行) ----- -dianyuntong: - enabled: false # true=执行, false=跳过 - base_url: "https://yndyt.cxtech.vip" - token: "Bearer NTmlyQYMYYEMwDTy1DDyxWjwykNZyQGO0jDOYYAMMQcNMgMN3jTy0jTYZlZh" # 独立 Bearer Token - cookie: "" # Cookie 字符串 (Admin-Token=xxx; sl-session=xxx) diff --git a/yun8_sync/db.py b/yun8_sync/db.py deleted file mode 100644 index 61ca183..0000000 --- a/yun8_sync/db.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -数据库操作 -========== -封装 ntocc.base_gd_mft_file 表的 INSERT 操作。 -""" - -import pymysql - - -class Database: - """MySQL 数据库写入""" - - def __init__(self, config: dict): - db = config["database"] - self.conn_kwargs = { - "host": db["host"], - "port": db.get("port", 3306), - "user": db["user"], - "password": db["password"], - "database": db["database"], - "charset": db.get("charset", "utf8mb4"), - } - sql_cfg = config["sql"] - self.file_name_template = sql_cfg["file_name_template"] - self.sql_template = sql_cfg["template"] - - def build_sql(self, file_prefix: str, pay_no: str) -> str: - """拼接完整 SQL(dry-run / 日志用)""" - file_name = self.file_name_template.format(file_prefix=file_prefix) - return self.sql_template.format(file_name=file_name, pay_no=pay_no) - - def insert(self, file_prefix: str, pay_no: str) -> int: - """执行 INSERT,返回 affected rows""" - sql = self.build_sql(file_prefix, pay_no) - conn = pymysql.connect(**self.conn_kwargs) - try: - with conn.cursor() as cur: - cur.execute(sql) - conn.commit() - return cur.rowcount - finally: - conn.close() - - def execute(self, sql: str) -> int: - """执行任意 SQL,返回 affected rows""" - conn = pymysql.connect(**self.conn_kwargs) - try: - with conn.cursor() as cur: - cur.execute(sql) - conn.commit() - return cur.rowcount - finally: - conn.close() diff --git a/yun8_sync/main.py b/yun8_sync/main.py deleted file mode 100644 index 888ec2d..0000000 --- a/yun8_sync/main.py +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env python3 -""" -yun8 运输流程完整自动化 -======================= -将 Postman Collection (yun8.postman_collection.json) 中 -"运输流程" 全部 17 个接口 + SQL 写入实现为 Python 流水线。 - - 阶段 A — 司机端 (步骤 1-5) 查询货源→接单→待运输→装货→卸货 - 阶段 B — 运费核算 (步骤 6-9) 查询→详情→试算→提交核算 - 阶段 C — 审核 & 付款 (步骤 10-16) 查询审核→审核通过→司机打款→货主打款 - 阶段 D — 回单文件 + SQL (步骤 14-16) payNo→打款记录→文件名前缀→INSERT - 阶段 E — 上报运单 (步骤 17) reportOrderDetail - -用法: - # 完整流程 - python main.py --auth-driver "Bearer xxx" --auth-admin "Bearer yyy" - - # 仅跑特定阶段 - python main.py --stage driver --auth-driver "Bearer xxx" --auth-admin "Bearer yyy" - - # 循环执行 10 次,间隔 3 秒 - python main.py --repeat 10 --delay 3 - - # 预览 SQL 不写库 - python main.py --dry-run -""" - -import argparse -import sys -import time -import traceback -from datetime import datetime -from pathlib import Path - -import yaml - -from pipeline import Pipeline - - -def load_config(path: str | Path) -> dict: - with open(path, "r", encoding="utf-8") as fh: - return yaml.safe_load(fh) - - -STAGE_HELP = """阶段选择: - all 完整流程 (默认): 司机端 → 核算 → 付款 → 回单SQL → 上报 - driver 仅司机端 (步骤 1-5) - accounting 仅运费核算 (步骤 6-9) - payment 仅审核付款 (步骤 10-16) - receipt 仅回单文件+SQL+上报 (步骤 14-17) -""" - - -def run_one_cycle(config: dict, stage: str, driver_token: str, - admin_token: str, dry_run: bool) -> bool: - """执行一次完整流水线,返回 True 表示成功""" - pipe = Pipeline(config) - pipe.set_auth(driver_token, admin_token) - - if stage in ("all", "driver"): - pipe.run_driver_flow() - - # reset_order_detail 模式:仅跑司机端,跳过后续全部步骤 - dp = config.get("driver_params", {}) - if dp.get("reset_order_detail", False): - print(f"\n [stop] reset_order_detail=true,跳过步骤 06-21") - return True - - if stage in ("all", "accounting"): - pipe.run_accounting_flow() - - if stage in ("all", "payment"): - pipe.run_audit_payment_flow() - - ac = config.get("accounting", {}) - if ac.get("execute_after_audit", True): - if stage in ("all", "receipt"): - pipe.run_receipt_file_flow(dry_run=dry_run) - # ETC 开票 SQL 写入 (步骤 22-23) - if stage in ("all", "receipt"): - pipe.run_etc_invoice_flow(dry_run=dry_run) - else: - if stage in ("all", "receipt"): - print(f"\n [stop] execute_after_audit=false,跳过 receipt 阶段") - - return True - - -def main(): - parser = argparse.ArgumentParser( - description="yun8 运输流程完整自动化", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=STAGE_HELP, - ) - parser.add_argument("--config", default=None, - help="配置文件路径 (默认: 脚本同目录下 config.yaml)") - parser.add_argument("--auth-driver", default="", - help="司机端 Bearer Token (含 'Bearer ' 前缀)") - parser.add_argument("--auth-admin", default="", - help="管理端 Bearer Token (含 'Bearer ' 前缀)") - parser.add_argument("--stage", default="all", - choices=["all", "driver", "accounting", "payment", "receipt"], - help="运行阶段 (默认: all)") - parser.add_argument("--dry-run", action="store_true", - help="仅预览 SQL,不实际写入数据库") - parser.add_argument("--repeat", type=int, default=1, - help="循环执行次数 (默认: 1)") - parser.add_argument("--delay", type=int, default=0, - help="每次循环之间的等待秒数 (默认: 0)") - args = parser.parse_args() - - # 加载配置 - config_path = args.config or (Path(__file__).parent / "config.yaml") - config = load_config(config_path) - - # 读取 Token - driver_token = args.auth_driver or config.get("auth", {}).get("driver_token", "") - admin_token = args.auth_admin or config.get("auth", {}).get("admin_token", "") - - print("=" * 60) - print(f" yun8 运输流程自动化") - print(f" config: {config_path}") - print(f" stage: {args.stage}") - print(f" dry-run: {args.dry_run}") - print(f" repeat: {args.repeat}") - print(f" delay: {args.delay}s") - print(f" 开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print("=" * 60) - - ok_count = 0 - fail_count = 0 - cycle_start = time.time() - - for i in range(1, args.repeat + 1): - if args.repeat > 1: - print(f"\n{'#' * 60}") - print(f"# 第 {i}/{args.repeat} 次循环") - print(f"# 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print(f"{'#' * 60}") - - try: - run_one_cycle(config, args.stage, driver_token, - admin_token, args.dry_run) - ok_count += 1 - if args.repeat > 1: - print(f"\n--- 第 {i}/{args.repeat} 次循环: 成功 ---") - - except KeyboardInterrupt: - print(f"\n 用户中断,已执行 {i-1} 次") - fail_count += (args.repeat - i + 1) - break - except Exception as exc: - fail_count += 1 - print(f"\n--- 第 {i}/{args.repeat} 次循环: 失败 ---") - print(f" 错误: {exc}") - traceback.print_exc() - # 失败后继续下一次(不中断循环) - - # 循环间隔 - if i < args.repeat and args.delay > 0: - print(f" 等待 {args.delay}s 后进入下一次循环...") - time.sleep(args.delay) - - # 汇总 - elapsed = time.time() - cycle_start - print(f"\n{'=' * 60}") - print(f" 全部循环完成") - print(f" 成功: {ok_count} 失败: {fail_count} 总计: {args.repeat}") - print(f" 耗时: {elapsed:.0f}s") - print(f" 结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print(f"{'=' * 60}") - - if fail_count > 0: - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/yun8_sync/requirements.txt b/yun8_sync/requirements.txt deleted file mode 100644 index 373865b..0000000 --- a/yun8_sync/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -requests>=2.28 -pymysql>=1.0 -PyYAML>=6.0