feat(yun8_sync): add API pipeline automating full freight flow (18 interfaces + SQL)

Implements the "运输流程" collection from yun8.postman_collection.json as a
Python pipeline: driver-side order accept/load/unload -> freight accounting
-> payment, with SQL writes, driven by yun8_sync/config.yaml
(api/auth/accounting/payment/database/sql/dianyuntong sections).
This commit is contained in:
xst
2026-08-17 14:30:40 +08:00
parent 18890c0437
commit 73b93bdf0c
7 changed files with 2357 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
# 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-23) │
│ 写入 base_etc_invoice → 写入 tms_order_etc_push │
└─────────────────────────────────────────────────────────────────┘
```
### 上下游变量传递
| 步骤 | 产出变量 | 消费步骤 |
|------|---------|---------|
| 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` | — |
> 步骤 22/23 的 `order_code`/`order_no` 取步骤 03 的 `orderCode`,`push_order_no` 为其后追加 `-1`,`order_detail_code` 取步骤 03 的 `code`,两个表的 `code` 按现有数据规则(32 位十六进制)自动生成,随 receipt 阶段执行(`--dry-run` 仅预览不写库)。
+965
View File
@@ -0,0 +1,965 @@
"""
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()
# ==================================================================
+127
View File
@@ -0,0 +1,127 @@
# ============================================================
# 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 mJ25zW3NYIYYZQcYMVQZ2QFNmTj5wkzMYMFZlj2NzDWk2mj12mT33QMMMIjM" # 司机端 Bearer token (含 Bearer 前缀)
admin_token: "Bearer Z=AZ3I3YlTTzhTGNOFDO5DDx1YhYMYz5hTTOOQUZkFTl5kFOMYdY3QWM2Em=" # 管理端 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"
# ----- 数据库配置 -----
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)
+53
View File
@@ -0,0 +1,53 @@
"""
数据库操作
==========
封装 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()
+178
View File
@@ -0,0 +1,178 @@
#!/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()
+791
View File
@@ -0,0 +1,791 @@
"""
流程编排器
==========
将 Postman Collection 中 16 步运输流程 + SQL 写入串联为完整流水线。
变量传递完全模拟 Postman 的 Collection Variables 机制:
步骤 N 的 output 传递给后续步骤作为 input。
用法:
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)
"""
import json
import time
import uuid
from typing import Optional
from client import Yun8Client
from db import Database
def _gen_code() -> str:
"""按现有数据规则生成 code: 32 位小写十六进制 (UUID4 hex 去横线)"""
return uuid.uuid4().hex
# ============================================================
# Pipeline — 变量上下文
# ============================================================
class Pipeline:
"""
模拟 Postman Collection Variables 的变量上下文。
每个步骤的输出写入 self.vars,后续步骤从 self.vars 读取。
"""
def __init__(self, config: dict):
self.config = config
self.client = Yun8Client(config)
self.db = Database(config)
# 所有步骤间共享的变量池(对应 Postman collectionVariables)
self.vars: dict = {}
# 动态 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))
self.client.dp["per_weight"] = val
self.client.dp["weight"] = val
print(f" [random] weight = {val}")
# ------------------------------------------------------------------
# 辅助方法
# ------------------------------------------------------------------
def _set_vars(self, **kwargs):
"""批量写入变量"""
self.vars.update(kwargs)
def _get(self, key: str, default: str = "") -> str:
"""读取变量"""
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}")
def _check_response(self, data: dict, step_name: str):
"""检查 API 返回 code 是否为 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}")
def _load_lat(self) -> str:
"""装货纬度: config.load_latitude → 货源 shipperLatitude → config.latitude"""
dp = self.config.get("driver_params", {})
return dp.get("load_latitude") or self._get("shipperLatitude") or dp.get("latitude", "")
def _load_lng(self) -> str:
"""装货经度: config.load_longitude → 货源 shipperLongitude → config.longitude"""
dp = self.config.get("driver_params", {})
return dp.get("load_longitude") or self._get("shipperLongitude") or dp.get("longitude", "")
def _unload_lat(self) -> str:
"""卸货纬度: config.unload_latitude → 货源 consigneeLatitude → config.latitude"""
dp = self.config.get("driver_params", {})
return dp.get("unload_latitude") or self._get("consigneeLatitude") or dp.get("latitude", "")
def _unload_lng(self) -> str:
"""卸货经度: config.unload_longitude → 货源 consigneeLongitude → config.longitude"""
dp = self.config.get("driver_params", {})
return dp.get("unload_longitude") or self._get("consigneeLongitude") or dp.get("longitude", "")
# ==================================================================
# 步骤 1-5 : 司机端流程
# ==================================================================
def step01_query_goods(self) -> dict:
self._log_step(1, "查询货源")
result = self.client.step01_query_goods()
self._set_vars(mainOrderCode=result["mainOrderCode"],
residueNumber=result["residueNumber"],
shipperLatitude=result["shipperLatitude"],
shipperLongitude=result["shipperLongitude"],
consigneeLatitude=result["consigneeLatitude"],
consigneeLongitude=result["consigneeLongitude"])
print(f" mainOrderCode = {result['mainOrderCode']}")
print(f" residueNumber = {result['residueNumber']}")
return result
def step02_accept_order(self) -> dict:
self._log_step(2, "接单")
data = self.client.step02_accept_order(
main_order_code=self._get("mainOrderCode"),
residue_number=self._get("residueNumber"),
latitude=self._load_lat(),
longitude=self._load_lng())
self._check_response(data, "接单")
print(f" 响应: code={data.get('code')}, msg={data.get('msg')}")
return data
def step03_query_pending_transport(self) -> dict:
self._log_step(3, "查询待运输单")
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']}")
return result
def step04_load_goods(self) -> dict:
self._log_step(4, "装货")
data = self.client.step04_load_goods(
code=self._get("code"),
vehicle_code=self._get("vehicleCode"),
latitude=self._load_lat(),
longitude=self._load_lng())
self._check_response(data, "装货")
print(f" 响应: code={data.get('code')}, msg={data.get('msg')}")
return data
def step05_unload_goods(self) -> dict:
self._log_step(5, "卸货")
data = self.client.step05_unload_goods(
code=self._get("code"),
latitude=self._unload_lat(),
longitude=self._unload_lng())
self._check_response(data, "卸货")
print(f" 响应: code={data.get('code')}, msg={data.get('msg')}")
return data
# ==================================================================
# 步骤 6-9 : 运费核算流程
# ==================================================================
def step06_query_for_accounting(self) -> dict:
self._log_step(6, "运费核算查询运单")
record = self.client.step06_query_for_accounting(
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])}...")
return record
def step07_query_order_expenses(self) -> dict:
self._log_step(7, "查询运单详情")
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}")
return result
def step08_calculate_order_detail(self) -> dict:
self._log_step(8, "核算试算 (calculateOrderDetail)")
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}")
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')}")
return data
# ==================================================================
# 步骤 10-16 : 审核 & 付款流程
# ==================================================================
def step10_query_audit(self) -> dict:
self._log_step(10, "查询运单审核")
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)}")
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')}")
return data
# Step 12-14 wrappers 不单独定义(已内联在 run_audit_payment_flow 中)
def step15_pay_to_driver(self) -> dict:
self._log_step(15, "垫资-财务打款")
data = self.client.step15_pay_to_driver(
code=self._get("code"),
delivery_cash_fee=self._get("deliveryCashFee"),
driver_actual_freight=self._get("driverActualFreight"),
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')}")
return data
def step16_apply_for_payment(self) -> dict:
self._log_step(16, "垫资-货主打款 (申请付款)")
data = self.client.step16_apply_for_payment(
code=self._get("code"),
total_amount=self._get("shipmentActualCash"))
self._check_response(data, "垫资-货主打款")
print(f" 响应: code={data.get('code')}, msg={data.get('msg')}")
return data
# ==================================================================
# 步骤 17-21 : 回单/文件名前缀
# ==================================================================
def step17_query_upload_for_payno(self) -> dict:
"""
14. 查询运单上传 → payNo
─────────────────
GET /ntocc-basic-api/dytBusiness/orderDetail (status=5)
Test: records[0].sendMsg → regex【(payNo)】
Input: orderCode
"""
self._log_step(17, "查询运单上传 (提取 payNo)")
result = self.client.step17_query_upload_for_payno(
order_code=self._get("orderCode"),
)
pay_no = result["payNo"]
self._set_vars(payNo=pay_no)
print(f" payNo = {pay_no}")
if not pay_no:
print(" [!] 未提取到 payNo,后续 SQL 中 front_seq_no 将为空")
return result
def step18_query_remit_records(self) -> list[dict]:
"""
15. 查询打款记录
─────────────────
GET /ntocc-tms-api/remitDefinite (分页)
Test: records[0].code → remitCode
Input: orderCode
"""
self._log_step(18, "查询打款记录")
records = self.client.step18_query_remit_records(
order_code=self._get("orderCode"),
)
print(f" 共 {len(records)} 条打款记录")
if records:
print(f" 首条字段: {list(records[0].keys())}")
return records
def step19_query_file_prefix(self, remit_code: str) -> str:
"""
16. 查询文件名前缀
─────────────────
GET /ntocc-tms-api/centralizedPayment/downLoadElectronicReceipt?code={remitCode}
Test: msg → '文件名前缀:{filePrefix}'
"""
file_prefix = self.client.step19_query_file_prefix(remit_code)
return file_prefix
# ==================================================================
# 组装流程
# ==================================================================
def run_driver_flow(self) -> dict:
"""仅跑司机端(步骤 1-5)"""
print("\n" + "=" * 60)
print(" 司机端流程 (Step 1-5)")
print("=" * 60)
self.step01_query_goods()
time.sleep(0.3)
self.step02_accept_order()
time.sleep(0.3)
self.step03_query_pending_transport()
# ---- 重置运单 (条件触发) ----
dp = self.config.get("driver_params", {})
if dp.get("reset_order_detail", False):
code = self._get("code")
order_code = self._get("orderCode")
print(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")
time.sleep(0.3)
# 重新查询待运输单,刷新变量
self.step03_query_pending_transport()
time.sleep(0.3)
self.step04_load_goods()
time.sleep(0.3)
self.step05_unload_goods()
# 卸货后 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")
else:
print(" [...] 等待 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")
return self.vars
def run_accounting_flow(self) -> dict:
"""运费核算流程(步骤 6-9)"""
print("\n" + "=" * 60)
print(" 运费核算流程 (Step 6-9)")
print("=" * 60)
self.step06_query_for_accounting()
time.sleep(0.5)
self.step07_query_order_expenses()
time.sleep(0.3)
self.step08_calculate_order_detail()
time.sleep(0.3)
self.step09_submit_accounting()
return self.vars
def _retry_on_processing(self, step_fn, step_name: str, max_retries: int = 5):
"""
遇到 "处理中" 错误时自动重试,每次间隔递增。
服务端审核通过后需要异步处理,可能持续数秒到十几秒。
"""
for attempt in range(1, max_retries + 1):
data = step_fn()
msg = str(data.get("msg", ""))
code = data.get("code")
if code == 0 or (msg and ("成功" in msg or "success" in msg.lower())):
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)...")
time.sleep(wait)
else:
# 非 "处理中" 错误,直接返回
return data
return data
def run_audit_payment_flow(self) -> dict:
"""审核 & 付款流程(步骤 10-16)"""
print("\n" + "=" * 60)
print(" 审核 & 付款流程 (Step 10-16)")
print("=" * 60)
# 确保前置依赖已获取(独立运行 --stage payment 时 vars 可能为空)
self._ensure_code_and_order_code()
self.step10_query_audit()
time.sleep(0.5)
self.step11_approve_audit()
print(" [...] 等待服务端处理审核结果...")
ac = self.config.get("accounting", {})
if not ac.get("execute_after_audit", True):
print(f"\n [stop] execute_after_audit=false,跳过步骤 12-21")
return self.vars
# ---- 队长分账 (条件触发:payerReceiverPair 非空 + captainSplitMode=2) ----
payer_receiver = ac.get("payer_receiver", "")
payer_link_man = ac.get("payer_receiver_link_man", "")
captain_split_mode = ac.get("captain_split_mode") or self._get("captainSplitMode")
if (payer_receiver or payer_link_man) and captain_split_mode == "2":
self._log_step(12, "队长分账预览 (captainSplitAdjustPreview)")
preview = self.client.step12_captain_split_preview(
order_detail_code=self._get("code"),
)
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}")
time.sleep(0.5)
self._log_step(13, "队长分账调整 (adjustCaptainSplit)")
# 计算 driverAmount/captainAmount (与 client 中计算逻辑一致)
import math as _m
_driver_amt = _m.ceil(latest_driver * 0.8 * 100) / 100.0
_captain_amt = round(latest_driver - _driver_amt, 2)
adjust = self.client.step13_adjust_captain_split(
order_detail_code=self._get("code"),
latest_driver_amount=latest_driver,
delivery_oil_fee=oil_fee,
)
self._check_response(adjust, "队长分账调整")
print(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}")
else:
print(f" [skip] 队长分账 (payerReceiver='{payer_receiver}', captainSplitMode={captain_split_mode})")
time.sleep(0.5)
# ---- Step 14: singleOil 带重试 ----
self._log_step(14, "单油卡充值 (singleOil)")
oil_amount = self._get("oilFee")
data_12 = self._retry_on_processing(
lambda: self.client.step14_single_oil(
order_detail_code=self._get("code"),
amount=oil_amount if oil_amount else "0.03",
),
"单油卡充值",
)
self._check_response(data_12, "singleOil")
print(f" 响应: code={data_12.get('code')}, msg={data_12.get('msg')}")
# Step 13 带重试
self._log_step(15, "垫资-财务打款")
data_12 = self._retry_on_processing(
lambda: self.client.step15_pay_to_driver(
code=self._get("code"),
delivery_cash_fee=self._get("deliveryCashFee"),
driver_actual_freight=self._get("driverActualFreight"),
plan_driver_payout=self._get("planDriverPayout", "0"),
plan_captain_payout=self._get("planCaptainPayout", "0"),
),
"垫资-财务打款",
)
self._check_response(data_12, "垫资-财务打款")
print(f" 响应: code={data_12.get('code')}, msg={data_12.get('msg')}")
# Step 14 带重试 (totalAmount 取 step06 的 shipmentActualCash)
self._log_step(16, "垫资-货主打款 (申请付款)")
data_13 = self._retry_on_processing(
lambda: self.client.step16_apply_for_payment(
code=self._get("code"),
total_amount=self._get("shipmentActualCash"),
),
"垫资-货主打款",
)
self._check_response(data_13, "垫资-货主打款")
print(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)
# 确保 orderCode 已就绪(独立运行 --stage receipt 时需要)
self._ensure_code_and_order_code()
# 14. 获取 payNo
self.step17_query_upload_for_payno()
time.sleep(0.3)
# 15. 获取所有打款记录
remit_records = self.step18_query_remit_records()
if not remit_records:
print(" [EXIT] 无打款记录")
return []
# 16 + SQL: 遍历每条打款记录
pay_no = self._get("payNo")
results = []
ok = 0
ng = 0
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 字段")
ng += 1
results.append({"remit_code": remit_code, "status": "skip_no_code"})
continue
print(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}")
if not file_prefix:
print(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}")
# SQL INSERT
sql = self.db.build_sql(file_prefix, pay_no)
if dry_run:
print(f" [DRY RUN] SQL:\n{sql}")
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})")
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}")
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)}")
# 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}")
# 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}")
else:
print(f"\n [skip] Step 19 滇运通审核 (dianyuntong.enabled=false)")
return results
# ==================================================================
# 步骤 22-23 : ETC 开票 SQL 写入
# ==================================================================
def step22_insert_etc_invoice(self, dry_run: bool = False) -> dict:
"""
22. 写入 ETC 开票信息 (base_etc_invoice)
─────────────────
INSERT INTO ntocc.base_etc_invoice
order_code ← 步骤03的 orderCode
push_order_no ← orderCode + "-1"
order_detail_code ← 步骤03的 code
code 按现有数据规则自动生成 (32 位十六进制)
"""
self._log_step(22, "ETC 开票信息写入 (base_etc_invoice)")
order_code = self._get("orderCode")
detail_code = self._get("code")
if not order_code or not detail_code:
print(" [!] orderCode/code 为空,无法写入 base_etc_invoice")
return {"table": "base_etc_invoice", "status": "skip_no_vars"}
etc_code = _gen_code()
push_order_no = f"{order_code}-1"
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, '{etc_code}', '{order_code}', '{push_order_no}', '{detail_code}', "
"'2026-06-04 23:23:51', '9141010055833160XE', '河南省视博电子股份有限公司', '广东古镇东站', '河北成安西站', "
"'2026-05-14 19:03:42', 0.02, 0.02, 0.03, 0.03, "
"'征税发票', '*', '26417910230600041165', '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', "
"'2026-05-14', '2026-05-14', '2026-05-14 19:03:42', "
"0, 'etc开票申请2026070604', '冀D8E016', 3, 0, "
"NULL, 0, '', '04b7e539c77d4bdbb4fe4e87eaaf41e4', '2026-07-06 09:55:16', '2026-07-06 10:03:26')"
)
print(f" order_code = {order_code}")
print(f" push_order_no = {push_order_no}")
print(f" order_detail_code = {detail_code}")
print(f" code = {etc_code}")
if dry_run:
print(f" [DRY RUN] SQL:\n{sql}")
return {"table": "base_etc_invoice", "status": "dry_run"}
try:
rows = self.db.execute(sql)
print(f" [OK] INSERT 成功 (rows: {rows})")
return {"table": "base_etc_invoice", "status": "inserted", "rows": rows}
except Exception as exc:
print(f" [FAIL] INSERT 失败: {exc}")
return {"table": "base_etc_invoice", "status": "error", "error": str(exc)}
def step23_insert_etc_push(self, dry_run: bool = False) -> dict:
"""
23. 写入 ETC 推送记录 (tms_order_etc_push)
─────────────────
INSERT INTO ntocc.tms_order_etc_push
order_no ← 步骤03的 orderCode
push_order_no ← orderCode + "-1"
order_detail_code ← 步骤03的 code
code 按现有数据规则自动生成 (32 位十六进制)
"""
self._log_step(23, "ETC 推送记录写入 (tms_order_etc_push)")
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")
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', 0, '2026-07-07 11:40:21', "
"2, '2026-07-07 15:17:02', '2', 2, '2026-07-07 15:11:04', 'dsdsad', "
"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}")
if dry_run:
print(f" [DRY RUN] SQL:\n{sql}")
return {"table": "tms_order_etc_push", "status": "dry_run"}
try:
rows = self.db.execute(sql)
print(f" [OK] INSERT 成功 (rows: {rows})")
return {"table": "tms_order_etc_push", "status": "inserted", "rows": rows}
except Exception as exc:
print(f" [FAIL] INSERT 失败: {exc}")
return {"table": "tms_order_etc_push", "status": "error", "error": str(exc)}
def run_etc_invoice_flow(self, dry_run: bool = False) -> list[dict]:
"""ETC 开票 SQL 写入(步骤 22-23)"""
print("\n" + "=" * 60)
print(" ETC 开票 SQL 写入 (Step 22-23)")
print("=" * 60)
# 确保 orderCode / code 已就绪(独立运行 --stage receipt 时需要)
self._ensure_code_and_order_code()
results = [
self.step22_insert_etc_invoice(dry_run=dry_run),
self.step23_insert_etc_push(dry_run=dry_run),
]
return results
def _ensure_code_and_order_code(self):
"""
确保 code / orderCode 等关键变量已就绪。
独立运行 --stage payment/receipt 时 vars 为空,
需自动补充。优先尝试 driver 端 step03(waitTrans 状态),
若订单已过 driver 阶段则降级为 admin 端查询(step10 接口)。
"""
if self._get("code") and self._get("orderCode"):
print(f" (复用已有 code={self._get('code')}, orderCode={self._get('orderCode')})")
return
print(" [*] 自动补充前置数据...")
# 方式 A: 通过 driver 端 step03(status=waitTrans)
driver_auth = self.client.driver.headers.get("authorization", "")
if driver_auth:
try:
result = self.client.step03_query_pending_transport()
self._set_vars(
code=result["code"],
vehicleCode=result["vehicleCode"],
orderCode=result["orderCode"],
)
print(f" [driver] code={self._get('code')}, orderCode={self._get('orderCode')}")
except Exception:
print(" driver 端无待运输单,切换 admin 端查询...")
driver_auth = "" # 标记失败,走降级
# 方式 B: 通过 admin 端(status=6 已结算 / status=5 已打款)
if not self._get("code") or not self._get("orderCode"):
for status_val in ["6", "5"]:
try:
# status=6 用 step10(/ntocc-tms-api/orderDetail?status=6&isSettle=1)
if status_val == "6":
data = self.client.step10_query_audit(
order_code="",
page_size=5,
)
records = data.get("data", {}).get("records", [])
else:
# status=5 用 step17(/ntocc-basic-api/dytBusiness/orderDetail?status=5)
data = self.client.step17_query_upload_for_payno(
order_code="", page_size=5,
)
records = data.get("raw_records", [])
if records:
r = records[0]
self._set_vars(
code=r.get("code", ""),
orderCode=r.get("orderCode", ""),
)
if self._get("code"):
print(f" [admin status={status_val}] code={self._get('code')}, orderCode={self._get('orderCode')}")
break
except Exception as e:
continue
if not self._get("code"):
print(" [!] 无法自动获取 code,请使用 --stage all 完整运行或手动指定")
return
# 补充核算数据(payment 阶段需要 deliveryCashFee/driverActualFreight)
if not self._get("deliveryCashFee"):
try:
record = self.client.step06_query_for_accounting(
order_code=self._get("orderCode"),
)
for k, v in record.items():
self.vars[k] = str(v) if v is not None else ""
expenses = self.client.step07_query_order_expenses(
code=self._get("code"),
)
self._set_vars(**expenses)
print(f" deliveryCashFee={self._get('deliveryCashFee')}, driverActualFreight={self._get('driverActualFreight')}")
except Exception as e:
print(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]}...")
+3
View File
@@ -0,0 +1,3 @@
requests>=2.28
pymysql>=1.0
PyYAML>=6.0