refactor(api): yun8_sync 并入 tests 分层框架(接口层 apis/yun8 + 用例层 api + Allure)

- tests/core: 新增 BaseHttpClient 统一 HTTP 封装(直连绕行/超时/日志/自定义编码);
  db_helper 追加 Db 长连接类(事务/mogrify/自动重连),既有函数不动
- tests/apis/yun8: 21 个 step 方法按域拆分为 5 个 Mixin(driver/accounting/
  audit_payment/receipt/third_party),Yun8Pipeline 24 步编排原样迁移,
  SQL 全部参数化并集中至 sql_templates
- tests/api: 5 阶段有序 P0 E2E 用例(业务失败由 print 改为真实报错)+
  README 迁移(删除明文密码段,修正步骤编号漂移)
- yun8_sync 目录退役:配置并入 test_config.yaml 的 yun8_api 段;
  造数 CLI 保留(python -m tests.apis.yun8.cli,兼容旧 config.yaml 结构)
- pytest.ini 注册 api/yun8_sync marker;requirements.txt 补 requests>=2.28
- 静态验证: compileall / 全仓 collect 24 条 / marker 过滤 / CLI --help /
  客户端与 pipeline 离线实例化 / SQL 占位符核对 / cross_end 兼容
This commit is contained in:
xst
2026-09-08 11:38:17 +08:00
parent e62e3224bf
commit 52b73f456c
26 changed files with 2147 additions and 1786 deletions
+13
View File
@@ -0,0 +1,13 @@
"""yun8 运输流程 API 客户端包。
分层:
client.py Yun8ApiClient 门面(3 套会话组装 + 鉴权)
driver_api.py 司机端 step01-05
accounting_api.py 运费核算 step06-09
audit_payment_api.py 审核 & 付款 step10-16
receipt_api.py 回单/上报 step17-20
third_party_api.py 滇运通 step21
sql_templates.py 全部 SQL 语句与参数化构造器
pipeline.py Yun8Pipeline 24 步编排
cli.py 造数 CLI 入口
"""
+337
View File
@@ -0,0 +1,337 @@
"""运费核算接口 Mixin — step06-09(管理端,含 ~190 字段核算提交报文)。
所有方法签名/返回值/默认值与旧 yun8_sync/client.py 逐字一致,
仅将原始 requests.Session 调用替换为 BaseHttpClient(self.admin_http)。
Usage:
from tests.apis.yun8.accounting_api import AccountingApi
class Yun8ApiClient(AccountingApi, ...): ...
"""
class AccountingApi:
"""运费核算接口(挂载于 Yun8ApiClient,依赖 admin_http / ac / dp 属性)。"""
# ==================================================================
# Step 06: 运费核算查询运单
# ==================================================================
def step06_query_for_accounting(self, order_code: str,
page_size: int = 20, page_num: int = 1) -> dict:
"""
Postman Test Script: 设置 30+ 个 collection variables
从 data.records[0] 中提取所有字段
Returns: dict with all fields from records[0]
"""
params = {
"status": "3",
"lastLoadingTimeRange": "",
"fillTimeRange": "",
"lastUnloadingTimeRange": "",
"signTimeRange": "",
"gmtModifiedRange": "",
"rangeValue_start_1": "",
"rangeValue_end_1": "",
"rangeValue_start_2": "",
"rangeValue_end_2": "",
"rangeValue_start_3": "",
"rangeValue_end_3": "",
"pageNum": page_num,
"pageSize": page_size,
"gmtCreatedStart": "",
"gmtCreatedEnd": "",
"lastUnloadingTimeStart": "",
"lastUnloadingTimeEnd": "",
"lastLoadingTimeStart": "",
"lastLoadingTimeEnd": "",
"gmtModifiedStart": "",
"gmtModifiedEnd": "",
"fillTimeStart": "",
"fillTimeEnd": "",
"signTimeStart": "",
"signTimeEnd": "",
"ordCreatedByName": "",
"isUnloadingConfirm": "1",
"isCannotAccounting": "0",
"riskControlStatus": "",
"riskControlReason": "",
"sortField": "",
"sortType": "",
"orderCode": order_code,
}
data = self.admin_http.get("/ntocc-tms-api/orderDetail/orderDetailHandlePageList", params)
records = data.get("data", {}).get("records", [])
if not records:
raise RuntimeError("运费核算查询运单: 无记录返回")
return records[0]
# ==================================================================
# Step 07: 查询运单详情
# ==================================================================
def step07_query_order_expenses(self, code: str) -> dict:
"""
Postman Test Script:
pm.collectionVariables.set("canWastage", ...);
pm.collectionVariables.set("deliveryCashFee", ...);
pm.collectionVariables.set("deliveryFeeDeserved", ...);
pm.collectionVariables.set("deliveryFeePractical", ...);
pm.collectionVariables.set("driverActualCash", ...);
pm.collectionVariables.set("driverActualFreight", ...);
Returns: {canWastage, deliveryCashFee, deliveryFeeDeserved,
deliveryFeePractical, driverActualCash, driverActualFreight}
"""
params = {"codes": code}
data = self.admin_http.post(
"/ntocc-tms-api/orderDetail/getOrderDetailExpensesParamList",
params=params)
items = data.get("data", [])
if not items:
raise RuntimeError("查询运单详情: 无数据返回")
r = items[0]
return {
"canWastage": str(r.get("canWastage", "")),
"deliveryCashFee": str(r.get("deliveryCashFee", "")),
"deliveryFeeDeserved": str(r.get("deliveryFeeDeserved", "")),
"deliveryFeePractical": str(r.get("deliveryFeePractical", "")),
"driverActualCash": str(r.get("driverActualCash", "")),
"driverActualFreight": str(r.get("driverActualFreight", "")),
}
# ==================================================================
# Step 08: 核算试算 (calculateOrderDetail)
# ==================================================================
def step08_calculate_order_detail(self, code: str) -> dict:
"""
Postman Test Script:
pm.collectionVariables.set("ticketImageUrls[]", jsonData.data.ticketImageUrls);
Returns: dict with ticketImageUrls + 其他 step08 返回的核算数据 (taxPrice 等)
"""
params = {"code": code, "isFirst": "1"}
data = self.admin_http.put("/ntocc-tms-api/orderDetail/calculateOrderDetail", params=params)
inner = data.get("data", {}) or {}
ticket_urls = inner.get("ticketImageUrls", "")
if isinstance(ticket_urls, list):
ticket_urls = ticket_urls[0] if ticket_urls else ""
# 返回所有有用字段,供 pipeline 存入 vars
result = {"ticketImageUrls": str(ticket_urls)}
for key in ("taxPrice", "taxFee", "taxPayFee", "taxPoint",
"price", "realPrice", "shipmentActualCash",
"shipmentActualFreight"):
if key in inner:
result[key] = str(inner[key])
return result
# ==================================================================
# Step 09: 核算提交
# ==================================================================
def step09_submit_accounting(self, accounting_vars: dict) -> dict:
"""
提交核算,参数来自前面步骤累积的所有变量。
accounting_vars 应包含 steps 03/06/07/08 的全部字段。
优先从 step06 返回值取值,取不到再用 config.yaml 默认值。
"""
av = accounting_vars
# 便捷取值:vars 优先 → config 兜底
def _v(key, cfg_key, default=""):
return av.get(key) or self.ac.get(cfg_key, default)
# Build the massive query string
params = {
"abnormalFee": "0",
"accountCheckAuthStatus": "0",
"accountCheckInfoAuthStatus": "0",
"accountCheckUpAuthStatus": "0",
"accountStatus": "0",
"allTrans": "0",
"allotAmount": "0",
"allotOil": "0",
"allotTaxAmount": "0",
"appSendLoadLocation": "0",
"appSendUnloadLocation": "0",
"applyStatus": "0",
"branchCode": _v("branchCode", "branch_code"),
"businessTypeCode": _v("businessTypeCode", "business_type_code"),
"canWastage": av.get("canWastage", ""),
"cancelTrans": "0",
"cancelType": "0",
"captainSplitCurrentVersion": "0",
"captainSplitMode": self.ac.get("captain_split_mode") or av.get("captainSplitMode", ""),
"cashDeposit": "0",
"coalName": "",
"coalPrice": av.get("coalPrice", ""),
"coalType": av.get("coalType", ""),
"coalTypeName": av.get("coalTypeName", ""),
"coalUnit": "0",
"coalUnitName": av.get("coalUnitName", ""),
"code": av.get("code", ""),
"contractStatus": "1",
"createdBy": av.get("createdBy", ""),
"createdByName": av.get("createdByName", ""),
"deleted": "0",
"deliveryCashFee": av.get("deliveryCashFee", ""),
"deliveryFeeDeserved": av.get("deliveryFeeDeserved", ""),
"deliveryFeePractical": av.get("deliveryFeePractical", ""),
"deliveryOilFee": "0",
"distance": av.get("distance", ""),
"driverActualCash": av.get("driverActualCash", ""),
"driverActualFreight": av.get("driverActualFreight", ""),
"driverCode": av.get("driverCode", ""),
"driverName": av.get("driverName", ""),
"driverPhone": av.get("driverPhone", ""),
"energyCardType": av.get("energyCardType", ""),
"etcType": "0",
"extraFee": "0",
"fillTime": av.get("fillTime", ""),
"finishedTrans": "0",
"gmtCreated": av.get("gmtCreated", ""),
"gmtModified": av.get("gmtModified", ""),
"id": av.get("id", ""),
"inFee": "0",
"infoFee": "0",
"insuranceAmount": "0",
"insuranceFee": "0",
"invoiceIsAll": "0",
"invoiceStatus": "0",
"invoiceStatusCaptain": "0",
"invoiceStatusDriver": "0",
"isAbnormal": "0",
"isCancel": "0",
"isCaptainOrder": "0",
"isCash": "0",
"isCuikuan": "0",
"isDriverComment": "0",
"isEqualDriver": "1",
"isEqualTeam": "1",
"isEqualVehicle": "1",
"isEtcHistory": "0",
"isFill": "1",
"isImport": "0",
"isInsure": "0",
"isLoadingConfirm": "1",
"isLoadingConfirmDate": av.get("isLoadingConfirmDate", ""),
"isOriginalOrder": "1",
"isPaperReturns": "0",
"isReceiptReturn": "0",
"isReportOrder": "0",
"isSend": "0",
"isSendDishonestyVehicle": "0",
"isSendFund": "0",
"isSendFundZhongLian": "0",
"isSendLoadLocation": "0",
"isSendTax": "0",
"isSendUnloadLocation": "0",
"isSendZhongLian": "0",
"isSettle": "0",
"isShipmentComment": "0",
"isSign": "1",
"isSplitOrder": "0",
"isSupplementary": "0",
"isTransfer": "0",
"isTrunkName": av.get("isTrunkName", ""),
"isUnloadingComfirmDate": av.get("isUnloadingComfirmDate", ""),
"isUnloadingConfirm": "1",
"isWsShare": "0",
"loadImgUrls[]": av.get("ticketImageUrls", ""),
"loadLatitude": av.get("loadLatitude", ""),
"loadLongitude": av.get("loadLongitude", ""),
"loadPlace": av.get("loadPlace", ""),
"loadQuantity": av.get("loadQuantity", ""),
"loadWeight": av.get("loadWeight", "1"),
"mainOrderCode": av.get("mainOrderCode", ""),
"modifiedBy": av.get("modifiedBy", ""),
"modifiedByName": av.get("modifiedByName", ""),
"monthlySettlementStatus": "0",
"oilFee": av.get("oilFee", ""),
"onTrans": "0",
"orderCode": av.get("orderCode", ""),
"payAmount": "0",
"payOil": "0",
"payStatus": "0",
"payStep": "10",
"payTaxAmount": "0",
"payTotalAmount": av.get("payTotalAmount", ""),
"payType": "0",
"payeeCode": _v("createdBy", "payee_code"), # step06 createdBy = payeeCode
"payeeName": av.get("payeeName", ""),
"payerReceiver": self.ac.get("payer_receiver", ""),
"payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""),
"platformPrepayment": "0",
"prepaidStatus": "0",
"price": av.get("price", ""),
"proofAmount": "0",
"proofStatus": "0",
"realPrice": av.get("realPrice", ""),
"receiptNum": "",
"receiptPayAmount": "0",
"refuelProportion": "0",
"reportMark": "0",
"reportStage": "0",
"settlementCash": "0",
"settlementOil": "0",
"settlementType": self.ac.get("settlement_type", "2"),
"settlementWeight": av.get("settlementWeight", "1"),
"shipmentActualCash": av.get("shipmentActualCash", ""),
"shipmentActualFreight": av.get("shipmentActualFreight", ""),
"shipmentCardScale": "10",
"shipmentDeliveryFeePractical": av.get("shipmentDeliveryFeePractical", ""),
"shipmentName": av.get("shipmentName", ""),
"shipmentPayStatus": "0",
"shipmentPhone": _v("shipmentPhone", "shipment_phone"),
"shipperDeliveryFee": av.get("shipperDeliveryFee", ""),
"shippingNoteNumber": av.get("shippingNoteNumber", ""),
"signTime": av.get("signTime", ""),
"status": "3",
"takeLatitude": av.get("takeLatitude", ""),
"takeLongitude": av.get("takeLongitude", ""),
"takeOrderType": "2",
"takePlace": av.get("takePlace", ""),
"taxFee": av.get("taxFee", ""),
"taxPayFee": av.get("taxPayFee", ""),
"taxPrice": av.get("taxPrice", ""),
"ticketImageUrls[]": av.get("ticketImageUrls", ""),
"txStatus": "0",
"unloadLatitude": self.dp.get("latitude", ""),
"unloadLongitude": self.dp.get("longitude", ""),
"unloadPlace": av.get("unloadPlace", ""),
"unloadQuantity": av.get("unloadQuantity", ""),
"unloadWeight": av.get("unloadWeight", "1"),
"vehicleCode": av.get("vehicleCode", ""),
"vehicleLicense": av.get("vehicleLicense", ""),
"waitCar": "0",
"waitCheckTrans": "0",
"waitDriverCheckTrans": "0",
"waitPay": "0",
"waitSettle": "0",
"waitTrans": "0",
"wastage": "0",
"weight": av.get("weight", "1"),
"whetherOverload": "0",
"wsShareRatio": "0",
"deductionFee": "0",
"driverErase": "0",
"driverFixedPay": "0",
"driverSettlementWeight": av.get("driverSettlementWeight", "1"),
"electricityCardScale": "10",
"gasCardScale": "10",
"isTrunk": "0",
"limitWastageAmount": "500",
"limitWastageType": "1",
"shipmentErase": "0",
"shipmentPartyCode": _v("shipmentPartyCode", "shipment_party_code"),
"shipmentSettlementWeight": av.get("shipmentSettlementWeight", "1"),
"shipperFixedPay": "0",
"taxPoint": av.get("taxPoint", ""),
"taxPriceFixedPay": "0",
"orderExtraFeeListStr": "[]",
"BranchCode": _v("branchCode", "branch_code"),
"unloadImgaes": av.get("ticketImageUrls", ""),
"truckLoadingImgaes": av.get("ticketImageUrls", ""),
"IsTaxPoint": "0",
"RealPrice": av.get("realPrice", ""),
"gmtCreated2": av.get("gmtCreated", ""),
"gmtModified2": av.get("gmtModified", ""),
}
return self.admin_http.request_with_encoded_query(
"PUT", "/ntocc-tms-api/orderDetail/orderHandleEditSubmitHandle", params=params)
+173
View File
@@ -0,0 +1,173 @@
"""审核 & 付款接口 Mixin — step10-16(管理端)。
所有方法签名/返回值/默认值与旧 yun8_sync/client.py 逐字一致,
仅将原始 requests.Session 调用替换为 BaseHttpClient(self.admin_http)。
Usage:
from tests.apis.yun8.audit_payment_api import AuditPaymentApi
class Yun8ApiClient(AuditPaymentApi, ...): ...
"""
import math
class AuditPaymentApi:
"""审核 & 付款接口(挂载于 Yun8ApiClient,依赖 admin_http / ac / pm 属性)。"""
# ==================================================================
# Step 10: 查询运单审核
# ==================================================================
def step10_query_audit(self, order_code: str,
page_size: int = 20, page_num: int = 1) -> dict:
"""
GET /ntocc-tms-api/orderDetail (status=6 已结算, isSettle=1)
Admin 端查询已结算运单用于审核。
"""
params = {
"isAbnormalHelp": "",
"gmtCreatedRange": "",
"lastLoadingTimeRange": "",
"fillTimeRange": "",
"lastUnloadingTimeRange": "",
"signTimeRange": "",
"settleTimeRange": "",
"gmtModifiedRange": "",
"pageNum": page_num,
"pageSize": page_size,
"isSettle": "1",
"status": "6",
"payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""),
"sortField": "",
"sortType": "",
"orderCode": order_code,
}
return self.admin_http.get("/ntocc-tms-api/orderDetail", params)
# ==================================================================
# Step 11: 核算审核通过
# ==================================================================
def step11_approve_audit(self, code: str) -> dict:
"""
POST /ntocc-tms-api/orderDetail/auditOrderDetail?orderDetailCodes={code}&isPass=true
"""
params = {"orderDetailCodes": code, "isPass": "true"}
return self.admin_http.request_with_encoded_query(
"POST", "/ntocc-tms-api/orderDetail/auditOrderDetail", params=params)
# ==================================================================
# Step 12: 队长分账预览 (captainSplitAdjustPreview)
# ==================================================================
def step12_captain_split_preview(self, order_detail_code: str) -> dict:
"""
GET /ntocc-tms-api/orderDetail/captainSplitAdjustPreview/{code}
仅在 payerReceiver 不为空且 captainSplitMode=2 时调用。
"""
path = f"/ntocc-tms-api/orderDetail/captainSplitAdjustPreview/{order_detail_code}"
return self.admin_http.get(path)
# Step 13: 队长分账调整 (adjustCaptainSplit)
# ==================================================================
def step13_adjust_captain_split(self, order_detail_code: str,
latest_driver_amount: float,
delivery_oil_fee: float) -> dict:
"""
POST /ntocc-tms-api/orderDetail/adjustCaptainSplit
Content-Type: application/json;charset=UTF-8
driverAmount = ceil(latestDriverAmount * 0.8 * 100) / 100
captainAmount = latestDriverAmount - driverAmount
oilFee = deliveryOilFee
"""
driver_amount = math.ceil(latest_driver_amount * 0.8 * 100) / 100.0
captain_amount = round(latest_driver_amount - driver_amount, 2)
body = {
"orderDetailCode": order_detail_code,
"driverAmount": str(driver_amount),
"captainAmount": str(captain_amount),
"oilFee": delivery_oil_fee,
}
return self.admin_http.post_json(
"/ntocc-tms-api/orderDetail/adjustCaptainSplit", body)
# Step 14: 单油卡充值 (singleOil)
# ==================================================================
def step14_single_oil(self, order_detail_code: str, amount: str = "0.03",
pay_type: str = "1", category: str = "1") -> dict:
"""
POST /ntocc-tms-api/finance/singleOil
Query params: payType, amount, orderDetailCode, category, isAdvancePayment
No body (content-length: 0)
在垫资-财务打款之前调用,处理油卡充值。
"""
params = {
"payType": pay_type,
"amount": amount,
"orderDetailCode": order_detail_code,
"category": category,
"isAdvancePayment": "1",
}
return self.admin_http.request_with_encoded_query(
"POST", "/ntocc-tms-api/finance/singleOil", params=params)
# ==================================================================
# Step 15: 垫资-财务打款
# ==================================================================
def step15_pay_to_driver(self, code: str, amount: str = None,
delivery_cash_fee: str = None,
driver_actual_freight: str = None,
plan_driver_payout: str = "0",
plan_captain_payout: str = "0") -> dict:
"""
POST /ntocc-tms-api/arpaTransfer/payToDriver
amount / deliveryCashFee / driverActualFreight 由 pipeline 从 step07 传入
planDriverPayout / planCaptainPayout 来自队长分账步骤,无分账时为 "0"
"""
if amount is None:
amount = "0.27"
if delivery_cash_fee is None:
delivery_cash_fee = "0.27"
if driver_actual_freight is None:
driver_actual_freight = "0.3"
params = {
"payTypes": self.pm.get("pay_types", "3,4,5,9,10,11"),
"amount": amount,
"orderDetailCode": code,
"infoFee": "0",
"pageNum": "1",
"pageSize": "20",
"totaltotal": "0",
"payerRole": "0",
"isAdvancePayment": "1",
"planDriverPayout": plan_driver_payout,
"planCaptainPayout": plan_captain_payout,
"allotAmount": "0",
"deliveryCashFee": delivery_cash_fee,
"driverActualFreight": driver_actual_freight,
"total": "0",
"payPlatform": self.pm.get("pay_platform", "3"),
"smsCode": self.pm.get("sms_code", "1234"),
"phone": self.pm.get("phone", "13888888888"),
}
return self.admin_http.request_with_encoded_query(
"POST", "/ntocc-tms-api/arpaTransfer/payToDriver", params=params)
# ==================================================================
# Step 16: 垫资-货主打款 (申请付款)
# ==================================================================
def step16_apply_for_payment(self, code: str, total_amount: str = None) -> dict:
"""
POST /ntocc-tms-api/orderDetail/applicationForPayment
totalAmount 应从 pipeline 传入 (step06 的 shipmentActualCash)
"""
if total_amount is None:
total_amount = "0.32"
params = {
"payType": self.pm.get("pay_type", "arpa_2"),
"totalAmount": total_amount,
"codes": code,
"payPlatform": self.pm.get("pay_platform", "3"),
"isAdvancePayment": "1",
}
return self.admin_http.request_with_encoded_query(
"POST", "/ntocc-tms-api/orderDetail/applicationForPayment", params=params)
+204
View File
@@ -0,0 +1,204 @@
"""yun8 运输流程造数 CLI。
旧 yun8_sync/main.py 的等价入口,复用 tests/apis/yun8 的 API 层与编排层:
python -m tests.apis.yun8.cli --stage all --repeat 10 --delay 5
注意:
- --dry-run 仅跳过回单/ETC 的 SQL 写入;HTTP 步骤全部真实执行,
driver 阶段的 reset SQL 也不受 dry-run 影响(与旧工具行为一致)。
- 数据库连接读取 tests/config/test_config.yaml 的 database 段。
阶段选择:
all 完整流程 (默认): 司机端 → 核算 → 付款 → 回单SQL → 上报
driver 仅司机端 (步骤 1-5)
accounting 仅运费核算 (步骤 6-9)
payment 仅审核付款 (步骤 10-16)
receipt 仅回单文件+SQL+上报 (步骤 14-17)
"""
import argparse
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
import yaml
from tests.core.config_loader import load_config
from tests.core.db_helper import Db
from tests.apis.yun8.pipeline import Yun8Pipeline
STAGE_HELP = """阶段选择:
all 完整流程 (默认): 司机端 → 核算 → 付款 → 回单SQL → 上报
driver 仅司机端 (步骤 1-5)
accounting 仅运费核算 (步骤 6-9)
payment 仅审核付款 (步骤 10-16)
receipt 仅回单文件+SQL+上报 (步骤 14-17)
"""
def _load_yun8_section(path: str | Path | None) -> dict:
"""加载 yun8_api 配置段。
- path 为空: 读 tests/config/test_config.yaml 的 yun8_api 段
- path 指向的 YAML 含 yun8_api 键: 直接取该段
- 否则按旧 yun8_sync/config.yaml 结构兼容解析(database 段忽略)
"""
if path is None:
return load_config()["yun8_api"]
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh)
if "yun8_api" in raw:
return raw["yun8_api"]
# 旧版结构兼容: api/auth/driver_params/... 平铺
api = raw.get("api", {})
section = {
"base_url": api.get("base_url", ""),
"verify_ssl": api.get("verify_ssl", False),
"timeout_seconds": api.get("timeout_seconds", 60),
"common_headers": api.get("common_headers", {}),
"driver_headers": api.get("driver", {}),
"admin_headers": api.get("admin", {}),
"tokens": dict(raw.get("auth", {})),
}
for key in ("driver_params", "accounting", "payment", "etc_invoice",
"sql", "dianyuntong"):
if key in raw:
section[key] = raw[key]
return section
def run_one_cycle(section: dict, stage: str, driver_token: str,
admin_token: str, dry_run: bool) -> bool:
"""执行一次完整流水线,返回 True 表示成功(异常直接向上抛)。"""
db = Db()
pipe = Yun8Pipeline(section, db)
try:
pipe.set_auth(driver_token, admin_token)
if stage in ("all", "driver"):
pipe.run_driver_flow()
# reset_order_detail 模式:仅跑司机端,跳过后续全部步骤
dp = section.get("driver_params", {})
if dp.get("reset_order_detail", False):
print(f"\n [stop] reset_order_detail=true,跳过步骤 06-21")
return True
if stage in ("all", "accounting"):
pipe.run_accounting_flow()
if stage in ("all", "payment"):
pipe.run_audit_payment_flow()
ac = section.get("accounting", {})
if ac.get("execute_after_audit", True):
if stage in ("all", "receipt"):
pipe.run_receipt_file_flow(dry_run=dry_run)
# ETC 开票 SQL 写入 (步骤 22-23)
pipe.run_etc_invoice_flow(dry_run=dry_run)
else:
if stage in ("all", "receipt"):
print(f"\n [stop] execute_after_audit=false,跳过 receipt 阶段")
return True
finally:
pipe.close()
db.close()
def main():
parser = argparse.ArgumentParser(
description="yun8 运输流程完整自动化",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=STAGE_HELP,
)
parser.add_argument("--config", default=None,
help="配置文件路径 (默认: tests/config/test_config.yaml 的 yun8_api 段)")
parser.add_argument("--auth-driver", default="",
help="司机端 Bearer Token (含 'Bearer ' 前缀)")
parser.add_argument("--auth-admin", default="",
help="管理端 Bearer Token (含 'Bearer ' 前缀)")
parser.add_argument("--stage", default="all",
choices=["all", "driver", "accounting", "payment", "receipt"],
help="运行阶段 (默认: all)")
parser.add_argument("--dry-run", action="store_true",
help="仅预览 SQL,不实际写入数据库 (注意: HTTP 步骤仍真实执行)")
parser.add_argument("--repeat", type=int, default=1,
help="循环执行次数 (默认: 1)")
parser.add_argument("--delay", type=int, default=0,
help="每次循环之间的等待秒数 (默认: 0)")
args = parser.parse_args()
# 加载配置
section = _load_yun8_section(args.config)
# 读取 Token(命令行优先)
tokens = section.get("tokens", {})
driver_token = args.auth_driver or tokens.get("driver_token", "")
admin_token = args.auth_admin or tokens.get("admin_token", "")
print("=" * 60)
print(f" yun8 运输流程自动化")
print(f" config: {args.config or 'tests/config/test_config.yaml (yun8_api)'}")
print(f" stage: {args.stage}")
print(f" dry-run: {args.dry_run}")
print(f" repeat: {args.repeat}")
print(f" delay: {args.delay}s")
print(f" 开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 60)
ok_count = 0
fail_count = 0
cycle_start = time.time()
for i in range(1, args.repeat + 1):
if args.repeat > 1:
print(f"\n{'#' * 60}")
print(f"# 第 {i}/{args.repeat} 次循环")
print(f"# 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'#' * 60}")
try:
run_one_cycle(section, args.stage, driver_token,
admin_token, args.dry_run)
ok_count += 1
if args.repeat > 1:
print(f"\n--- 第 {i}/{args.repeat} 次循环: 成功 ---")
except KeyboardInterrupt:
print(f"\n 用户中断,已执行 {i-1} 次")
fail_count += (args.repeat - i + 1)
break
except Exception as exc:
fail_count += 1
print(f"\n--- 第 {i}/{args.repeat} 次循环: 失败 ---")
print(f" 错误: {exc}")
traceback.print_exc()
# 失败后继续下一次(不中断循环)
# 循环间隔
if i < args.repeat and args.delay > 0:
print(f" 等待 {args.delay}s 后进入下一次循环...")
time.sleep(args.delay)
# 汇总
elapsed = time.time() - cycle_start
print(f"\n{'=' * 60}")
print(f" 全部循环完成")
print(f" 成功: {ok_count} 失败: {fail_count} 总计: {args.repeat}")
print(f" 耗时: {elapsed:.0f}s")
print(f" 结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'=' * 60}")
if fail_count > 0:
sys.exit(1)
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
"""yun8 API 客户端门面。
组装司机端 / 管理端 / 第三方(滇运通)三套 BaseHttpClient 会话,
并挂载各业务域 Mixin 的 step01-21 方法。
请求头组装顺序与旧 yun8_sync/client.py._init_session 逐字一致:
1. 角色专属请求头(跳过空值)
2. common_headers 中的 host / accept-language / accept-encoding 无条件覆盖
Usage:
from tests.apis.yun8.client import Yun8ApiClient
client = Yun8ApiClient(config["yun8_api"])
client.set_driver_auth("Bearer xxx")
client.set_admin_auth("Bearer yyy")
data = client.step01_query_goods()
"""
from tests.core.http_client import BaseHttpClient
from tests.apis.yun8.driver_api import DriverApi
from tests.apis.yun8.accounting_api import AccountingApi
from tests.apis.yun8.audit_payment_api import AuditPaymentApi
from tests.apis.yun8.receipt_api import ReceiptApi
from tests.apis.yun8.third_party_api import ThirdPartyApi
class Yun8ApiClient(DriverApi, AccountingApi, AuditPaymentApi, ReceiptApi,
ThirdPartyApi):
"""封装 yun8 全部 API 请求(21 个 step 方法 + 3 套会话)。"""
def __init__(self, config: dict):
# config 为 test_config.yaml 的 yun8_api section
self.base_url = config["base_url"].rstrip("/")
self.verify_ssl = config.get("verify_ssl", False)
self.timeout = config.get("timeout_seconds", 60)
common = config.get("common_headers", {})
# ---- Driver Session ----
self.driver_http = BaseHttpClient(
self.base_url, headers=config.get("driver_headers", {}),
verify_ssl=self.verify_ssl, timeout=self.timeout, name="yun8-driver")
# ---- Admin Session ----
self.admin_http = BaseHttpClient(
self.base_url, headers=config.get("admin_headers", {}),
verify_ssl=self.verify_ssl, timeout=self.timeout, name="yun8-admin")
# 通用请求头无条件覆盖(与旧版一致,即使为空也写入)
for http in (self.driver_http, self.admin_http):
http.set_header("host", common.get("host", ""))
http.set_header("accept-language", common.get("accept-language", ""))
http.set_header("accept-encoding", common.get("accept-encoding", ""))
# ---- Third-party Session (滇运通,独立 token/host) ----
tp = config.get("dianyuntong", {})
self.tp_enabled = tp.get("enabled", False)
self.tp_http = None
self.tp_base = ""
if self.tp_enabled:
self.tp_base = tp.get("base_url", "").rstrip("/")
tp_headers = {
"host": "yndyt.cxtech.vip",
"authorization": tp.get("token", ""),
"deviceid": "0000000000",
"secret": "GouldAMap=d58bb5f7da1aa1d6b78741ded2450f2d",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0",
"accept": "application/json, text/plain, */*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
"origin": tp.get("base_url", ""),
"referer": f"{tp.get('base_url', '')}/admin/",
"cookie": tp.get("cookie", ""),
}
self.tp_http = BaseHttpClient(
self.tp_base, headers=tp_headers,
verify_ssl=self.verify_ssl, timeout=self.timeout, name="yun8-tp")
# 快捷引用(各域 Mixin 使用)
self.dp = config.get("driver_params", {})
self.ac = config.get("accounting", {})
self.pm = config.get("payment", {})
# ------------------------------------------------------------------
def set_driver_auth(self, token: str):
self.driver_http.set_header("authorization", token)
def set_admin_auth(self, token: str):
self.admin_http.set_header("authorization", token)
def close(self):
"""关闭全部会话连接池。"""
self.driver_http.close()
self.admin_http.close()
if self.tp_http:
self.tp_http.close()
+163
View File
@@ -0,0 +1,163 @@
"""司机端接口 Mixin — step01-05(对应 Postman Collection 司机端 5 个接口)。
所有方法签名/返回值/默认值与旧 yun8_sync/client.py 逐字一致,
仅将原始 requests.Session 调用替换为 BaseHttpClient(self.driver_http)。
Usage:
from tests.apis.yun8.driver_api import DriverApi
class Yun8ApiClient(DriverApi, ...): ...
"""
from datetime import datetime
from tests.core.http_client import BaseHttpClient
def _current_time_str() -> str:
"""Postman pre-request: 生成 'YYYY-MM-DD HH:MM:SS' 格式的当前时间"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class DriverApi:
"""司机端接口(挂载于 Yun8ApiClient,依赖 driver_http / dp 属性)。"""
# ==================================================================
# Step 01: 查询货源
# ==================================================================
def step01_query_goods(self, page_size: int = 10, page_num: int = 1) -> dict:
"""
Postman Test Script:
pm.collectionVariables.set("mainOrderCode", jsonData.data.records[0].code);
pm.collectionVariables.set("residueNumber", jsonData.data.records[0].lastWeight);
Returns: {"mainOrderCode": str, "residueNumber": str}
"""
params = {
"isFromOrderHall": "1",
"sortType": "desc",
"sortField": "gmt_created",
"pageSize": page_size,
"pageNum": page_num,
}
data = self.driver_http.get("/ntocc-tms-api/order/orderHallList", params)
records = data.get("data", {}).get("records", [])
if not records:
raise RuntimeError("查询货源: 无记录返回")
r = records[0]
return {
"mainOrderCode": r.get("code", ""),
"residueNumber": str(r.get("lastWeight", "")),
"shipperLatitude": str(r.get("shipperLatitude", "")),
"shipperLongitude": str(r.get("shipperLongitude", "")),
"consigneeLatitude": str(r.get("consigneeLatitude", "")),
"consigneeLongitude": str(r.get("consigneeLongitude", "")),
}
# ==================================================================
# Step 02: 接单
# ==================================================================
def step02_accept_order(self, main_order_code: str, residue_number: str,
message_code: str = "",
latitude: str = None, longitude: str = None) -> dict:
"""
Postman Body (urlencoded):
residueNumber / perWeight / latitude / messageCode / mainOrderCode / longitude
latitude/longitude 优先取货源接口的 shipperLat/shipperLng,没有则用 config
"""
data = {
"residueNumber": residue_number,
"perWeight": self.dp.get("per_weight", "1"),
"latitude": latitude or self.dp.get("latitude", ""),
"messageCode": message_code,
"mainOrderCode": main_order_code,
"longitude": longitude or self.dp.get("longitude", ""),
}
return self.driver_http.post("/ntocc-tms-api/orderDetail/receive", data=data)
# ==================================================================
# Step 03: 查询待运输单
# ==================================================================
def step03_query_pending_transport(self, page_size: int = 10, page_num: int = 1) -> dict:
"""
Postman Test Script:
pm.collectionVariables.set("code", jsonData.data.records[0].code);
pm.collectionVariables.set("vehicleCode", jsonData.data.records[0].vehicleCode);
pm.collectionVariables.set("orderCode", jsonData.data.records[0].orderCode);
Returns: {"code": str, "vehicleCode": str, "orderCode": str}
"""
params = {
"assignmentStatus": "waitTrans",
"placeOfReceipt": "",
"gmtCreatedEnd": "",
"gmtCreatedStart": "",
"pageSize": page_size,
"placeOfShipment": "",
"pageNum": page_num,
"shipmentName": "",
"status": "1",
"vehicleLicense": "",
}
data = self.driver_http.get("/ntocc-tms-api/orderDetail", params)
records = data.get("data", {}).get("records", [])
if not records:
raise RuntimeError("查询待运输单: 无记录返回")
r = records[0]
return {
"code": r.get("code", ""),
"vehicleCode": r.get("vehicleCode", ""),
"orderCode": r.get("orderCode", ""),
}
# ==================================================================
# Step 04: 装货
# ==================================================================
def step04_load_goods(self, code: str, vehicle_code: str,
img_url: str = "", quantity: str = "",
weight: str = None,
latitude: str = None, longitude: str = None) -> dict:
"""
Postman Pre-request Script: 生成 currentTime
Body: imgUrl / orderType / quantity / code / latitude / weight / vehicleCode / time / longitude
latitude/longitude 优先取货源接口的 shipperLat/shipperLng
"""
if weight is None:
weight = self.dp.get("weight", "1")
body = {
"imgUrl": img_url or self.dp.get("load_img_url", ""),
"orderType": "1",
"quantity": quantity,
"code": code,
"latitude": latitude or self.dp.get("latitude", ""),
"weight": weight,
"vehicleCode": vehicle_code,
"time": _current_time_str(),
"longitude": longitude or self.dp.get("longitude", ""),
}
return self.driver_http.put("/ntocc-tms-api/orderDetail/fill", data=body)
# ==================================================================
# Step 05: 卸货
# ==================================================================
def step05_unload_goods(self, code: str, img_url: str = "",
quantity: str = "", weight: str = None,
latitude: str = None, longitude: str = None) -> dict:
"""
Postman Pre-request Script: 生成 currentTime
Body: imgUrl / code / quantity / latitude / weight / time / longitude
latitude/longitude 优先取货源接口的 consigneeLat/consigneeLng
"""
if weight is None:
weight = self.dp.get("weight", "1")
body = {
"imgUrl": img_url or self.dp.get("unload_img_url", ""),
"code": code,
"quantity": quantity,
"latitude": latitude or self.dp.get("latitude", ""),
"weight": weight,
"time": _current_time_str(),
"longitude": longitude or self.dp.get("longitude", ""),
}
return self.driver_http.put("/ntocc-tms-api/orderDetail/uploadTicketImage", data=body)
+917
View File
@@ -0,0 +1,917 @@
"""yun8 运输流程编排器。
将 Postman Collection 中运输流程 18 个接口 + SQL 写入串联为完整流水线,
变量传递模拟 Postman 的 Collection Variables 机制(步骤 N 的输出写入
self.vars,供后续步骤读取)。
与旧 yun8_sync/pipeline.py 的差异(均为迁移计划内的有意行为变化):
- _check_response 业务 code != 0 时抛 Yun8StepError(旧版仅 print)
- step17 未提取到 payNo 时抛 Yun8StepError(旧版警告后继续,写库 front_seq_no 为空)
- step20 上报运单 / step21 滇运通审核失败不再吞异常,向上传播
- SQL 全部参数化,reset 序列包进事务,连接复用
- print → 统一 logger(output/logs/ 文件 + 控制台)
Usage:
from tests.apis.yun8.pipeline import Yun8Pipeline
pipe = Yun8Pipeline(config["yun8_api"], db)
pipe.set_auth(driver_token, admin_token)
pipe.run_driver_flow() # 步骤 1-5
"""
import random
import time
import uuid
from datetime import datetime, timedelta
from tests.core.db_helper import Db
from tests.core.logger import get_logger
from tests.apis.yun8.client import Yun8ApiClient
from tests.apis.yun8.sql_templates import (
RECEIPT_FILE_INSERT_SQL,
ETC_INVOICE_INSERT_SQL,
ETC_PUSH_INSERT_SQL,
etc_invoice_insert_params,
etc_status_update,
post_unload_reset_update,
receipt_file_insert_params,
driver_reset_statements,
risk_control_update,
)
class Yun8StepError(RuntimeError):
"""yun8 流程步骤失败(业务 code != 0 或关键变量提取失败)。"""
def __init__(self, step_name: str, code=None, msg: str = ""):
self.step_name = step_name
self.code = code
self.msg = msg
detail = f"{step_name} 失败"
if code is not None:
detail += f" (code={code}, msg={msg})"
elif msg:
detail += f" ({msg})"
super().__init__(detail)
def _gen_code() -> str:
"""按现有数据规则生成 code: 32 位小写十六进制 (UUID4 hex 去横线)"""
return uuid.uuid4().hex
# ETC 发票路段链(广东→河北,按行驶方向依次经过;每条发票取相邻两站作为进/出站)
_ETC_STATION_CHAIN = [
"广东古镇东站", "湖南郴州西站", "湖南岳阳东站",
"湖北孝感东站", "河南信阳站", "河北成安西站",
]
# ============================================================
# Yun8Pipeline — 变量上下文
# ============================================================
class Yun8Pipeline:
"""
模拟 Postman Collection Variables 的变量上下文。
每个步骤的输出写入 self.vars,后续步骤从 self.vars 读取。
"""
def __init__(self, config: dict, db: Db):
# config 为 test_config.yaml 的 yun8_api section
self.config = config
self.client = Yun8ApiClient(config)
self.db = db
self.log = get_logger("apis.yun8.pipeline")
# 所有步骤间共享的变量池(对应 Postman collectionVariables)
self.vars: dict = {}
# 回单 file_name 格式模板(旧 config.sql.file_name_template)
self.file_name_template = config.get("sql", {}).get(
"file_name_template", "{file_prefix}_1_signed.pdf")
# 动态 weight 模式:每次循环随机生成 1~10 之间的小数,per_weight 和 weight 保持一致
dp = config.get("driver_params", {})
if dp.get("weight_mode", "fixed") == "random":
val = str(round(random.uniform(1.0, 10.0), 1))
self.client.dp["per_weight"] = val
self.client.dp["weight"] = val
self.log.info(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):
self.log.info(f"\n{'─' * 50}")
self.log.info(f"[Step {num:02d}] {name}")
self.log.info(f"{'─' * 50}")
def _check_response(self, data: dict, step_name: str):
"""检查 API 返回 code 是否为 0,非 0 视为步骤失败并中断流程。"""
code = data.get("code")
msg = data.get("msg", "")
if code is not None and code != 0:
raise Yun8StepError(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"])
self.log.info(f" mainOrderCode = {result['mainOrderCode']}")
self.log.info(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, "接单")
self.log.info(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"])
self.log.info(f" code = {result['code']}")
self.log.info(f" vehicleCode = {result['vehicleCode']}")
self.log.info(f" orderCode = {result['orderCode']}")
return result
def step04_load_goods(self) -> dict:
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, "装货")
self.log.info(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, "卸货")
self.log.info(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 ""
self.log.info(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():
self.log.info(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():
self.log.info(f" {k} = {v[:80] if len(str(v)) > 80 else v}")
return result
def step09_submit_accounting(self) -> dict:
self._log_step(9, "核算提交 (orderHandleEditSubmitHandle)")
data = self.client.step09_submit_accounting(self.vars)
self._check_response(data, "核算提交")
self.log.info(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", [])
self.log.info(f" 记录数: {len(records)}")
return data
def step11_approve_audit(self) -> dict:
self._log_step(11, "核算审核通过")
data = self.client.step11_approve_audit(code=self._get("code"))
self._check_response(data, "核算审核通过")
self.log.info(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, "垫资-财务打款")
self.log.info(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, "垫资-货主打款")
self.log.info(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)
self.log.info(f" payNo = {pay_no}")
if not pay_no:
# 迁移行为变化:旧版仅警告并继续(INSERT 空 front_seq_no),现视为失败
raise Yun8StepError("查询运单上传", msg="未提取到 payNo(sendMsg 中无【】包裹内容)")
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"),
)
self.log.info(f" 共 {len(records)} 条打款记录")
if records:
self.log.info(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)"""
self.log.info("\n" + "=" * 60)
self.log.info(" 司机端流程 (Step 1-5)")
self.log.info("=" * 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")
self.log.info(f"\n [reset] 重置运单 orderCode={order_code}")
reset_code = dp.get("reset_order_detail_code", "")
with self.db.transaction():
for sql, params in driver_reset_statements(reset_code, order_code):
rows = self.db.execute(sql, params)
self.log.info(f" SQL: {self.db.mogrify(sql, params)[:80]}... → {rows} rows")
time.sleep(0.3)
# 重新查询待运输单,刷新变量
self.step03_query_pending_transport()
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, params = post_unload_reset_update(reset_code, self._get("orderCode"))
rows = self.db.execute(sql, params)
self.log.info(f" [reset] 卸货后 UPDATE: {rows} rows")
else:
self.log.info(" [...] 等待 10s ...")
time.sleep(10)
sql, params = risk_control_update(self._get("code"))
rows = self.db.execute(sql, params)
self.log.info(f" 卸货后 risk_control_status UPDATE: {rows} rows")
return self.vars
def run_accounting_flow(self) -> dict:
"""运费核算流程(步骤 6-9)"""
self.log.info("\n" + "=" * 60)
self.log.info(" 运费核算流程 (Step 6-9)")
self.log.info("=" * 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 秒
self.log.info(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)"""
self.log.info("\n" + "=" * 60)
self.log.info(" 审核 & 付款流程 (Step 10-16)")
self.log.info("=" * 60)
# 确保前置依赖已获取(独立运行 --stage payment 时 vars 可能为空)
self._ensure_code_and_order_code()
self.step10_query_audit()
time.sleep(0.5)
self.step11_approve_audit()
self.log.info(" [...] 等待服务端处理审核结果...")
ac = self.config.get("accounting", {})
if not ac.get("execute_after_audit", True):
self.log.info(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))
self.log.info(f" latestDriverAmount = {latest_driver}")
self.log.info(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, "队长分账调整")
self.log.info(f" 响应: code={adjust.get('code')}, msg={adjust.get('msg')}")
# 存入 vars,供 step13 财务打款使用
self._set_vars(planDriverPayout=str(_driver_amt),
planCaptainPayout=str(_captain_amt))
self.log.info(f" planDriverPayout = {_driver_amt}")
self.log.info(f" planCaptainPayout = {_captain_amt}")
else:
self.log.info(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")
self.log.info(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, "垫资-财务打款")
self.log.info(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, "垫资-货主打款")
self.log.info(f" 响应: code={data_13.get('code')}, msg={data_13.get('msg')}")
return self.vars
def run_receipt_file_flow(self, dry_run: bool = False) -> list[dict]:
"""回单文件流程(步骤 15-18)+ SQL INSERT"""
self.log.info("\n" + "=" * 60)
self.log.info(" 回单文件流程 (Step 14-16 + SQL)")
self.log.info("=" * 60)
# 确保 orderCode 已就绪(独立运行 --stage receipt 时需要)
self._ensure_code_and_order_code()
# 14. 获取 payNo
self.step17_query_upload_for_payno()
time.sleep(0.3)
# 15. 获取所有打款记录
remit_records = self.step18_query_remit_records()
if not remit_records:
self.log.info(" [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:
self.log.info(f"\n [{i+1}/{len(remit_records)}] 跳过:缺 code 字段")
ng += 1
results.append({"remit_code": remit_code, "status": "skip_no_code"})
continue
self.log.info(f"\n [{i+1}/{len(remit_records)}] remitCode = {remit_code}")
# 16. 查询文件名前缀
file_prefix = self.step19_query_file_prefix(remit_code)
self.log.info(f" filePrefix = {file_prefix}")
if not file_prefix:
self.log.info(f" [!] filePrefix 为空,跳过 SQL INSERT")
ng += 1
results.append({"remit_code": remit_code, "status": "skip_no_prefix"})
continue
file_name = self.file_name_template.format(file_prefix=file_prefix)
self.log.info(f" file_name = {file_name}")
self.log.info(f" payNo = {pay_no}")
# SQL INSERT(参数化;dry-run 用 mogrify 渲染预览)
sql = RECEIPT_FILE_INSERT_SQL
params = receipt_file_insert_params(file_prefix, pay_no,
self.file_name_template)
if dry_run:
self.log.info(f" [DRY RUN] SQL:\n{self.db.mogrify(sql, params)}")
ok += 1
results.append({"remit_code": remit_code, "file_prefix": file_prefix,
"status": "dry_run"})
else:
try:
rowcount = self.db.execute(sql, params)
self.log.info(f" [OK] INSERT 成功 (rows: {rowcount})")
ok += 1
results.append({"remit_code": remit_code, "file_prefix": file_prefix,
"status": "inserted", "rows": rowcount})
except Exception as exc:
self.log.info(f" [FAIL] INSERT 失败: {exc}")
ng += 1
results.append({"remit_code": remit_code, "file_prefix": file_prefix,
"status": "error", "error": str(exc)})
# 汇总
self.log.info(f"\n ───────────────────────")
self.log.info(f" 回单文件流程完成: 成功 {ok}, 失败 {ng}, 总计 {len(remit_records)}")
# Step 18: 上报运单(迁移行为变化:失败不再吞异常,向上传播)
self._log_step(20, "上报运单 (reportOrderDetail)")
data_17 = self.client.step20_report_order_detail(
codes=self._get("code"),
)
self._check_response(data_17, "上报运单")
self.log.info(f" 响应: code={data_17.get('code')}, msg={data_17.get('msg')}")
# Step 19: 滇运通审核 (独立 token/host,config 控制开关)
# (迁移行为变化:失败不再吞异常,向上传播)
if self.client.tp_enabled:
self._log_step(21, "滇运通审核 (updateOrderDetailExt)")
data_19 = self.client.step21_owner_review(
order_detail_code=self._get("code"),
)
self._check_response(data_19, "滇运通审核")
self.log.info(f" 响应: code={data_19.get('code')}, msg={data_19.get('msg')}")
else:
self.log.info(f"\n [skip] Step 19 滇运通审核 (dianyuntong.enabled=false)")
return results
# ==================================================================
# 步骤 22-24 : ETC 开票 SQL 写入
# ==================================================================
def _gen_etc_invoice(self, idx: int, total: int, amount: float) -> dict:
"""
生成第 idx 条发票的差异字段(发票号码/金额/进出站/时间)。
金额由调用方传入(已按"所有发票合计 ≤ 运费×50%"约束分配)。
时间规则: transaction_time(=ex_time) 在运单窗口
2026-05-13 15:35:38 ~ 2026-05-15 13:35:43 内按条数均分、逐条推进,
en_date=ex_date=该时间日期;invoice_time 在 2026-06-04 ~ 06-06 内随机。
"""
invoice_number = "264" + "".join(random.choices("0123456789", k=17))
pair = idx % (len(_ETC_STATION_CHAIN) - 1)
w_start = datetime(2026, 5, 13, 15, 35, 38)
w_end = datetime(2026, 5, 15, 13, 35, 43)
seg = (w_end - w_start) / total
trans_time = w_start + seg * idx + timedelta(
seconds=random.uniform(0, seg.total_seconds()))
invoice_time = datetime(2026, 6, 4) + timedelta(
seconds=random.uniform(0, 3 * 86400))
return {
"code": _gen_code(),
"invoice_number": invoice_number,
"total_price": amount,
"amount": amount,
"import_station": _ETC_STATION_CHAIN[pair],
"export_station": _ETC_STATION_CHAIN[pair + 1],
"transaction_time": trans_time.strftime("%Y-%m-%d %H:%M:%S"),
"ex_time": trans_time.strftime("%Y-%m-%d %H:%M:%S"),
"en_date": trans_time.strftime("%Y-%m-%d"),
"ex_date": trans_time.strftime("%Y-%m-%d"),
"invoice_time": invoice_time.strftime("%Y-%m-%d %H:%M:%S"),
}
def step22_insert_etc_invoices(self, dry_run: bool = False) -> list[dict]:
"""
22. 写入多条 ETC 开票信息 (base_etc_invoice)
─────────────────
每个运单插入 etc_invoice.count 条发票(config.yaml 配置,默认 3),
每条的 发票号码/金额/进站/出站/时间 各不相同,其余字段按原固定值:
order_code ← 步骤03的 orderCode
push_order_no ← orderCode + "-1"
order_detail_code ← 步骤03的 code
code 按现有数据规则自动生成 (32 位十六进制),每条独立
金额约束: 所有发票金额合计 ≤ 运费(优先 driverActualFreight,其次
shipmentActualFreight) × 50%;运费未知或上限不足时跳过发票写入。
"""
self._log_step(22, "ETC 开票信息写入 (base_etc_invoice × N)")
order_code = self._get("orderCode")
detail_code = self._get("code")
if not order_code or not detail_code:
self.log.info(" [!] orderCode/code 为空,无法写入 base_etc_invoice")
return [{"table": "base_etc_invoice", "status": "skip_no_vars"}]
raw_count = self.config.get("etc_invoice", {}).get("count", 3)
n = int(raw_count) if raw_count is not None else 3
push_order_no = f"{order_code}-1"
self.log.info(f" order_code = {order_code}")
self.log.info(f" push_order_no = {push_order_no}")
self.log.info(f" order_detail_code = {detail_code}")
self.log.info(f" 发票条数 = {n}")
if n <= 0:
self.log.info(" [skip] etc_invoice.count <= 0,跳过")
return []
# ---- 金额约束: 所有发票金额合计 ≤ 运费 × 50% ----
freight_raw = self._get("driverActualFreight") or self._get("shipmentActualFreight")
try:
freight = float(freight_raw)
except (TypeError, ValueError):
freight = 0.0
freight_src = ("driverActualFreight" if self._get("driverActualFreight")
else "shipmentActualFreight")
cap = freight * 0.5
cap_floor = int(cap * 100) / 100 # 向下取整到分,确保不超上限
self.log.info(f" 运费({freight_src}) = {freight:.2f},发票金额上限(运费×50%) = {cap_floor:.2f}")
if cap_floor < 0.01 * n:
self.log.info(f" [!] 上限 {cap_floor:.2f} 不足以分配 {n} 张发票(每张至少 0.01),跳过发票写入")
return [{"table": "base_etc_invoice", "status": "skip_amount_cap"}]
# 随机总额: 上限的 30%~100%,且保证每张至少 0.01;按整数分分配,合计精确
total_amt = min(cap_floor, max(0.01 * n,
round(cap_floor * random.uniform(0.3, 1.0), 2)))
total_cents = int(round(total_amt * 100))
alloc = [0] * n # 每张保底 1 分
pool = total_cents - n
if pool > 0:
weights = [random.uniform(0.5, 1.5) for _ in range(n)]
wsum = sum(weights)
alloc = [int(pool * w / wsum) for w in weights]
for _ in range(pool - sum(alloc)):
alloc[random.randrange(n)] += 1
amounts = [(1 + a) / 100 for a in alloc]
# 写入前显式判断(按构造不应触发,作兜底保护)
if round(sum(amounts), 2) > round(cap, 2):
self.log.info(f" [!] 发票合计 {sum(amounts):.2f} 超过运费×50% {cap:.2f},跳过发票写入")
return [{"table": "base_etc_invoice", "status": "skip_amount_cap"}]
self.log.info(f" 发票金额合计 = {sum(amounts):.2f}({n} 张,各 {['%.2f' % a for a in amounts]})≤ 上限,校验通过")
results = []
for i in range(n):
inv = self._gen_etc_invoice(i, n, amounts[i])
sql = ETC_INVOICE_INSERT_SQL
params = etc_invoice_insert_params(inv, order_code, push_order_no,
detail_code)
self.log.info(f" [{i+1}/{n}] 发票号={inv['invoice_number']} 金额={inv['total_price']:.2f} "
f"{inv['import_station']}→{inv['export_station']} "
f"交易时间={inv['transaction_time']} 开票时间={inv['invoice_time']}")
if dry_run:
self.log.info(f" [DRY RUN] SQL:\n{self.db.mogrify(sql, params)}")
results.append({"table": "base_etc_invoice",
"invoice_number": inv["invoice_number"],
"status": "dry_run"})
continue
try:
rows = self.db.execute(sql, params)
self.log.info(f" [OK] INSERT 成功 (rows: {rows})")
results.append({"table": "base_etc_invoice",
"invoice_number": inv["invoice_number"],
"status": "inserted", "rows": rows})
except Exception as exc:
self.log.info(f" [FAIL] INSERT 失败: {exc}")
results.append({"table": "base_etc_invoice",
"invoice_number": inv["invoice_number"],
"status": "error", "error": str(exc)})
ok = sum(1 for r in results if r["status"] in ("inserted", "dry_run"))
ng = len(results) - ok
self.log.info(f" base_etc_invoice 完成: 成功 {ok}, 失败 {ng}, 总计 {n}")
return results
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:
self.log.info(" [!] orderCode/code 为空,无法写入 tms_order_etc_push")
return {"table": "tms_order_etc_push", "status": "skip_no_vars"}
etc_code = _gen_code()
push_order_no = f"{order_code}-1"
sql = ETC_PUSH_INSERT_SQL
params = (etc_code, push_order_no, order_code, detail_code)
self.log.info(f" order_no = {order_code}")
self.log.info(f" push_order_no = {push_order_no}")
self.log.info(f" order_detail_code = {detail_code}")
self.log.info(f" code = {etc_code}")
if dry_run:
self.log.info(f" [DRY RUN] SQL:\n{self.db.mogrify(sql, params)}")
return {"table": "tms_order_etc_push", "status": "dry_run"}
try:
rows = self.db.execute(sql, params)
self.log.info(f" [OK] INSERT 成功 (rows: {rows})")
return {"table": "tms_order_etc_push", "status": "inserted", "rows": rows}
except Exception as exc:
self.log.info(f" [FAIL] INSERT 失败: {exc}")
return {"table": "tms_order_etc_push", "status": "error", "error": str(exc)}
def step24_update_order_etc_status(self, dry_run: bool = False) -> dict:
"""
24. 更新运单 ETC 开票状态 (tms_order_detail)
─────────────────
UPDATE ntocc.tms_order_detail SET etc_invoice_status = 3
WHERE order_code = 步骤03的 orderCode
"""
self._log_step(24, "更新运单 ETC 开票状态 (tms_order_detail.etc_invoice_status=3)")
order_code = self._get("orderCode")
if not order_code:
self.log.info(" [!] orderCode 为空,无法更新 tms_order_detail")
return {"table": "tms_order_detail", "status": "skip_no_vars"}
sql, params = etc_status_update(order_code)
self.log.info(f" order_code = {order_code}")
if dry_run:
self.log.info(f" [DRY RUN] SQL:\n{self.db.mogrify(sql, params)}")
return {"table": "tms_order_detail", "status": "dry_run"}
try:
rows = self.db.execute(sql, params)
self.log.info(f" [OK] UPDATE 成功 (rows: {rows})")
return {"table": "tms_order_detail", "status": "updated", "rows": rows}
except Exception as exc:
self.log.info(f" [FAIL] UPDATE 失败: {exc}")
return {"table": "tms_order_detail", "status": "error", "error": str(exc)}
def run_etc_invoice_flow(self, dry_run: bool = False) -> list[dict]:
"""ETC 开票 SQL 写入(步骤 22-24)"""
self.log.info("\n" + "=" * 60)
self.log.info(" ETC 开票 SQL 写入 (Step 22-24)")
self.log.info("=" * 60)
# 确保 orderCode / code 已就绪(独立运行 --stage receipt 时需要)
self._ensure_code_and_order_code()
results = []
results.extend(self.step22_insert_etc_invoices(dry_run=dry_run))
results.append(self.step23_insert_etc_push(dry_run=dry_run))
results.append(self.step24_update_order_etc_status(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"):
self.log.info(f" (复用已有 code={self._get('code')}, orderCode={self._get('orderCode')})")
return
self.log.info(" [*] 自动补充前置数据...")
# 方式 A: 通过 driver 端 step03(status=waitTrans)
driver_auth = self.client.driver_http.get_header("authorization")
if driver_auth:
try:
result = self.client.step03_query_pending_transport()
self._set_vars(
code=result["code"],
vehicleCode=result["vehicleCode"],
orderCode=result["orderCode"],
)
self.log.info(f" [driver] code={self._get('code')}, orderCode={self._get('orderCode')}")
except Exception:
self.log.info(" 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"):
self.log.info(f" [admin status={status_val}] code={self._get('code')}, orderCode={self._get('orderCode')}")
break
except Exception as e:
self.log.debug(f" [admin status={status_val}] 查询失败: {e}")
continue
if not self._get("code"):
self.log.info(" [!] 无法自动获取 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)
self.log.info(f" deliveryCashFee={self._get('deliveryCashFee')}, driverActualFreight={self._get('driverActualFreight')}")
except Exception as e:
self.log.info(f" [!] 补充核算字段失败: {e}")
def set_auth(self, driver_token: str, admin_token: str):
"""设置授权 Token"""
self.client.set_driver_auth(driver_token)
self.client.set_admin_auth(admin_token)
self.log.info(f" Driver auth: {driver_token[:30]}...")
self.log.info(f" Admin auth: {admin_token[:30]}...")
def close(self):
"""关闭 HTTP 会话连接池(数据库连接由调用方管理)。"""
self.client.close()
+142
View File
@@ -0,0 +1,142 @@
"""回单/上报接口 Mixin — step17-20(管理端)。
所有方法签名/返回值/默认值与旧 yun8_sync/client.py 逐字一致,
仅将原始 requests.Session 调用替换为 BaseHttpClient(self.admin_http)。
Usage:
from tests.apis.yun8.receipt_api import ReceiptApi
class Yun8ApiClient(ReceiptApi, ...): ...
"""
import re
class ReceiptApi:
"""回单/上报接口(挂载于 Yun8ApiClient,依赖 admin_http / ac 属性)。"""
# ==================================================================
# Step 17: 查询运单上传 → 提取 payNo
# ==================================================================
def step17_query_upload_for_payno(self, order_code: str,
page_size: int = 20, page_num: int = 1) -> dict:
"""
Postman Test Script:
var sendMsg = jsonData.data.records[0].sendMsg || "";
var match = sendMsg.match(/【([^】]+)】/);
var result = match ? match[1] : "";
pm.collectionVariables.set("payNo", result);
Returns: {"payNo": str, "raw_records": list}
"""
params = {
"gmtCreatedRange[]": ["", ""],
"lastLoadingTimeRange": "",
"fillTimeRange": "",
"lastUnloadingTimeRange": "",
"signTimeRange": "",
"settleTimeRange": "",
"gmtModifiedRange": "",
"pageNum": page_num,
"pageSize": page_size,
"status": "5", # <-- 注意:status=5
"payerReceiverLinkMan": self.ac.get("payer_receiver_link_man", ""),
"sortField": "",
"sortType": "",
"gmtCreatedStart": "",
"gmtCreatedEnd": "",
"orderCode": order_code,
}
data = self.admin_http.get("/ntocc-basic-api/dytBusiness/orderDetail", params)
records = data.get("data", {}).get("records", [])
pay_no = ""
if records:
send_msg = records[0].get("sendMsg", "")
match = re.search(r"【([^】]+)】", send_msg)
if match:
pay_no = match.group(1)
return {"payNo": pay_no, "raw_records": records}
# ==================================================================
# Step 18: 查询打款记录 → 提取所有 code
# ==================================================================
def step18_query_remit_records(self, order_code: str,
page_size: int = 20) -> list[dict]:
"""
Postman Test Script:
pm.collectionVariables.set("remitCode", jsonData.data.records[0].code);
分页获取全部打款记录。Returns: [{code, ...}, ...]
"""
all_records = []
page_num = 1
while True:
params = {
"pageNum": page_num,
"pageSize": page_size,
"pageForm": "arpa_2",
"orderDetailNumber": order_code,
"sortField": "",
"sortType": "",
"isPayAdvance": "",
"platPayType": "",
"gmtModifiedStart": "",
"gmtModifiedEnd": "",
}
data = self.admin_http.get("/ntocc-tms-api/remitDefinite", params)
# code 可能是 0 (成功) 或 None/null (也是成功)
resp_code = data.get("code")
if resp_code is not None and resp_code != 0:
raise RuntimeError(f"查询打款记录失败: {data.get('msg')}")
page_data = data.get("data", {})
records = page_data.get("records", [])
all_records.extend(records)
total = page_data.get("total", 0)
if page_num * page_size >= total:
break
page_num += 1
return all_records
# ==================================================================
# Step 19: 查询文件名前缀 → 提取 filePrefix
# ==================================================================
def step19_query_file_prefix(self, remit_code: str) -> str:
"""
Postman Test Script:
const msg = jsonData.msg || '';
const prefix = '文件名前缀:';
const index = msg.indexOf(prefix);
const filePrefix = index !== -1 ? msg.substring(index + prefix.length).trim() : '';
pm.collectionVariables.set('filePrefix', filePrefix);
Returns: filePrefix string
"""
params = {"code": remit_code}
data = self.admin_http.get(
"/ntocc-tms-api/centralizedPayment/downLoadElectronicReceipt",
params)
msg = data.get("msg", "")
prefix_marker = "文件名前缀:"
idx = msg.find(prefix_marker)
if idx != -1:
return msg[idx + len(prefix_marker):].strip()
return ""
# ==================================================================
# Step 20: 上报运单 (reportOrderDetail)
# ==================================================================
def step20_report_order_detail(self, codes: str, report_type: int = 1) -> dict:
"""
POST /ntocc-basic-api/dytBusiness/reportOrderDetail
Content-Type: application/json;charset=UTF-8
Body: {"codes": "...", "type": 1}
在打款完成后上报运单,完成整个流程闭环。
"""
body = {"codes": codes, "type": report_type}
return self.admin_http.post_json(
"/ntocc-basic-api/dytBusiness/reportOrderDetail", body)
+130
View File
@@ -0,0 +1,130 @@
"""yun8 全部 SQL 语句与参数化构造器。
所有语句统一使用 %s 参数占位(由 Db.execute 交给 pymysql 转义),
字面量列值(固定 code/时间戳/URL 等)从旧 yun8_sync 实现逐字保留。
Usage:
from tests.apis.yun8.sql_templates import (
RECEIPT_FILE_INSERT_SQL, receipt_file_insert_params,
ETC_INVOICE_INSERT_SQL, ETC_PUSH_INSERT_SQL,
driver_reset_statements, post_unload_reset_update, risk_control_update,
)
"""
# ── 回单文件 INSERT (base_gd_mft_file) ────────────────────────────
# 仅 file_name / front_seq_no(=payNo) 为参数;其余列值固定。
RECEIPT_FILE_INSERT_SQL = """
INSERT INTO `ntocc`.`base_gd_mft_file`
(`id`, `code`, `file_name`, `remark`, `is_deleted`,
`created_by`, `gmt_created`, `modified_by`, `gmt_modified`,
`modified_by_name`, `is_directory`, `parent_directory`,
`local_path`, `url_path`, `front_seq_no`)
VALUES
(NULL, '10cd8a53b921463ab8079ea61376574e', %s,
'批量写入回单编号20260608', 0,
'', '2025-12-21 16:24:11', '', '2025-12-21 16:24:11',
'', 0, '415612025121420251215274330494862543.zip',
'/data/cebBankService/tempFile/415612025121420251215274330494862543/41561202512141330494250466_1_signed.pdf',
'https://public-yun8.eos-chongqing-1.cmecloud.cn/d83a1298991040a4a125a84acadd88b9.pdf',
%s)
"""
def receipt_file_insert_params(file_prefix: str, pay_no: str,
file_name_template: str) -> tuple:
"""回单 INSERT 参数: (file_name, pay_no)。"""
file_name = file_name_template.format(file_prefix=file_prefix)
return (file_name, pay_no)
# ── ETC 发票 INSERT (base_etc_invoice) ────────────────────────────
# 参数: code, order_code, push_order_no, order_detail_code, invoice_time,
# import_station, export_station, transaction_time, total_price,
# amount, invoice_number, en_date, ex_date, ex_time
ETC_INVOICE_INSERT_SQL = (
"INSERT INTO `ntocc`.`base_etc_invoice` ("
"`id`, `code`, `order_code`, `push_order_no`, `order_detail_code`, "
"`invoice_time`, `sale_tax_code`, `sale_name`, `import_station`, `export_station`, "
"`transaction_time`, `total_price`, `amount`, `tax_rate`, `tax_amount`, "
"`invoice_type`, `invoice_code`, `invoice_number`, `waybill_start_time`, `waybill_end_time`, "
"`invoice_url`, `invoice_html_url`, `en_date`, `ex_date`, `ex_time`, "
"`is_valid`, `task_name`, `plate_num`, `etc_invoice_status`, `tax_deduction_status`, "
"`sign`, `is_deleted`, `created_by`, `modified_by`, `gmt_created`, `gmt_modified`) "
"VALUES ("
"NULL, %s, %s, %s, %s, "
"%s, '9141010055833160XE', '河南省视博电子股份有限公司', "
"%s, %s, "
"%s, %s, %s, 0.03, 0.03, "
"'征税发票', '*', %s, '2026-05-13 15:35:38', '2026-05-15 13:35:43', "
"'https://hdfs.txffp.com/e/1102e1en66nPPP1ChAeC161e1C6Pei16.zip?Expires=1812122633&OSSAccessKeyId=LTAIYAJbICRhyCD8&Signature=TLjlHgKtCxwWpPliPRQ0xQ6HdKY%3D', "
"'https://gateway.txffp.com/alle/common/htmlurl/getinvoice/41012526221804003093/1102e1en66nPPP1ChAeC161e1C6Pei16', "
"%s, %s, %s, "
"0, 'etc开票申请2026070604', '冀D8E016', 3, 0, "
"NULL, 0, '', '04b7e539c77d4bdbb4fe4e87eaaf41e4', '2026-07-06 09:55:16', '2026-07-06 10:03:26')"
)
def etc_invoice_insert_params(inv: dict, order_code: str, push_order_no: str,
order_detail_code: str) -> tuple:
"""ETC 发票 INSERT 参数(inv 为 pipeline._gen_etc_invoice 的产出)。"""
return (
inv["code"], order_code, push_order_no, order_detail_code,
inv["invoice_time"],
inv["import_station"], inv["export_station"],
inv["transaction_time"], inv["total_price"], inv["amount"],
inv["invoice_number"],
inv["en_date"], inv["ex_date"], inv["ex_time"],
)
# ── ETC 推送记录 INSERT (tms_order_etc_push) ──────────────────────
# 参数: code, push_order_no, order_no, order_detail_code;其余固定。
ETC_PUSH_INSERT_SQL = (
"INSERT INTO `ntocc`.`tms_order_etc_push` ("
"`id`, `code`, `push_order_no`, `order_no`, `order_detail_code`, "
"`start_time`, `end_time`, `year`, `month`, `vendor`, "
"`request`, `start_request`, `end_request`, `invoice_pull_status`, `invoice_pull_time`, "
"`start_status`, `start_push_time`, `start_result`, `end_status`, `end_push_time`, `end_result`, "
"`is_deleted`, `created_by`, `modified_by`, `gmt_created`, `gmt_modified`) "
"VALUES ("
"NULL, %s, %s, %s, %s, "
"'2026-07-07 15:08:25', '2026-07-07 15:08:28', 2026, 6, 1, "
"NULL, '11111', '11111', 2, '2026-07-07 11:40:21', "
"1, '2026-07-07 15:17:02', '成功', 1, '2026-07-07 15:11:04', '成功', "
"0, NULL, NULL, '2026-07-07 11:09:17', '2026-07-07 15:15:18')"
)
# ── 司机流程 reset / 风控状态 UPDATE ──────────────────────────────
def driver_reset_statements(reset_code: str, order_code: str) -> list[tuple]:
"""step03 后的重置序列(3 DELETE + 1 UPDATE),需在一个事务内执行。"""
return [
("DELETE FROM tms_order_detail WHERE code = %s", (reset_code,)),
("DELETE FROM tms_order_detail_ext WHERE order_detail_code = %s", (reset_code,)),
("DELETE FROM tms_order_detail_abnormal_status WHERE order_detail_code = %s", (reset_code,)),
("UPDATE tms_order_detail SET code = %s WHERE order_code = %s",
(reset_code, order_code)),
]
def post_unload_reset_update(reset_code: str, order_code: str) -> tuple:
"""reset 模式卸货后的运单号/装货签收时间回写。"""
sql = ("UPDATE tms_order_detail SET code = %s, "
"fill_time = '2026-07-05 19:30:56', "
"sign_time = '2026-07-05 19:52:30' "
"WHERE order_code = %s")
return (sql, (reset_code, order_code))
def risk_control_update(order_detail_code: str, status: int = 1) -> tuple:
"""卸货后风控状态回写(按 order_detail_code 直更)。"""
sql = ("UPDATE tms_order_detail_ext SET risk_control_status = %s "
"WHERE order_detail_code = %s")
return (sql, (status, order_detail_code))
def etc_status_update(order_code: str, status: int = 3) -> tuple:
"""运单 ETC 开票状态更新 (tms_order_detail.etc_invoice_status)。"""
sql = ("UPDATE `ntocc`.`tms_order_detail` SET etc_invoice_status = %s "
"WHERE order_code = %s")
return (sql, (status, order_code))
+34
View File
@@ -0,0 +1,34 @@
"""第三方(滇运通)接口 Mixin — step21。
签名/返回值与旧 yun8_sync/client.py 逐字一致;
滇运通为独立 host/token 会话(self.tp_http,base_url 为滇运通域名)。
Usage:
from tests.apis.yun8.third_party_api import ThirdPartyApi
class Yun8ApiClient(ThirdPartyApi, ...): ...
"""
class ThirdPartyApi:
"""滇运通接口(挂载于 Yun8ApiClient,依赖 tp_http / tp_enabled 属性)。"""
# ==================================================================
# Step 21: 滇运通审核 (updateOrderDetailExt)
# ==================================================================
def step21_owner_review(self, order_detail_code: str) -> dict:
"""
POST /ntocc-tms-api/orderDetail/updateOrderDetailExt
滇运通独立接口,独立 token / host。
仅在 config.dianyuntong.enabled=true 时执行。
"""
if not self.tp_enabled or not self.tp_http:
raise RuntimeError("dianyuntong 未启用,请检查配置 yun8_api.dianyuntong.enabled")
params = {
"orderDetailCode": order_detail_code,
"reviewStatus": "1",
"reviewMsg": "",
"invoiceApplicationStatus": "0",
"invoiceApplicationMsg": "",
}
return self.tp_http.post(
"/ntocc-tms-api/orderDetail/updateOrderDetailExt", params=params)