From 73b93bdf0c61a823cdb07e14a0f2b67963f11de4 Mon Sep 17 00:00:00 2001 From: xst Date: Mon, 17 Aug 2026 14:30:40 +0800 Subject: [PATCH] feat(yun8_sync): add API pipeline automating full freight flow (18 interfaces + SQL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- yun8_sync/README.md | 240 +++++++++ yun8_sync/client.py | 965 +++++++++++++++++++++++++++++++++++++ yun8_sync/config.yaml | 127 +++++ yun8_sync/db.py | 53 ++ yun8_sync/main.py | 178 +++++++ yun8_sync/pipeline.py | 791 ++++++++++++++++++++++++++++++ yun8_sync/requirements.txt | 3 + 7 files changed, 2357 insertions(+) create mode 100644 yun8_sync/README.md create mode 100644 yun8_sync/client.py create mode 100644 yun8_sync/config.yaml create mode 100644 yun8_sync/db.py create mode 100644 yun8_sync/main.py create mode 100644 yun8_sync/pipeline.py create mode 100644 yun8_sync/requirements.txt diff --git a/yun8_sync/README.md b/yun8_sync/README.md new file mode 100644 index 0000000..b6ee266 --- /dev/null +++ b/yun8_sync/README.md @@ -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` 仅预览不写库)。 diff --git a/yun8_sync/client.py b/yun8_sync/client.py new file mode 100644 index 0000000..dca5712 --- /dev/null +++ b/yun8_sync/client.py @@ -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() + + # ================================================================== diff --git a/yun8_sync/config.yaml b/yun8_sync/config.yaml new file mode 100644 index 0000000..951cc14 --- /dev/null +++ b/yun8_sync/config.yaml @@ -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) diff --git a/yun8_sync/db.py b/yun8_sync/db.py new file mode 100644 index 0000000..61ca183 --- /dev/null +++ b/yun8_sync/db.py @@ -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() diff --git a/yun8_sync/main.py b/yun8_sync/main.py new file mode 100644 index 0000000..888ec2d --- /dev/null +++ b/yun8_sync/main.py @@ -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() diff --git a/yun8_sync/pipeline.py b/yun8_sync/pipeline.py new file mode 100644 index 0000000..4bb6da8 --- /dev/null +++ b/yun8_sync/pipeline.py @@ -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]}...") diff --git a/yun8_sync/requirements.txt b/yun8_sync/requirements.txt new file mode 100644 index 0000000..373865b --- /dev/null +++ b/yun8_sync/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.28 +pymysql>=1.0 +PyYAML>=6.0