""" 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() # ==================================================================