diff --git a/backend/app/api/logistics.py b/backend/app/api/logistics.py index 2d90524..9c49cf3 100644 --- a/backend/app/api/logistics.py +++ b/backend/app/api/logistics.py @@ -355,7 +355,7 @@ def get_logistics_trace( order_id: int, # 订单ID logistics_service: LogisticsService = Depends(get_logistics_service), # 物流服务实例 session: Session = Depends(get_db_session), # 数据库会话 - current_user: dict = Depends(require_roles("manager", "admin", "driver")), # 当前登录用户 + current_user: dict = Depends(require_roles("manager", "admin", "driver", "salesman")), # 当前登录用户 _permission_user: dict = Depends(require_permissions("logistics:trace:list")), # 权限校验:轨迹查看权限 ) -> dict: """ @@ -369,7 +369,7 @@ def get_logistics_trace( 返回值: 物流轨迹列表,按时间倒序排列 - 权限要求:manager、admin、driver 角色,需 logistics:trace:list 权限 + 权限要求:manager、admin、driver、salesman 角色,需 logistics:trace:list 权限 """ return success_payload(logistics_service.get_trace(order_id, session, current_user)) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 594defe..0dca791 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -78,6 +78,7 @@ class AuthService: "customer:list", "reminder:list", "ai:parse-order", + "logistics:trace:list", ], "manager": [ "order:list", diff --git a/backend/app/services/bootstrap.py b/backend/app/services/bootstrap.py index 03546a4..2666962 100644 --- a/backend/app/services/bootstrap.py +++ b/backend/app/services/bootstrap.py @@ -167,6 +167,7 @@ DEFAULT_ROLE_MENU_CODES = { "customer:list", "reminder:list", "ai:parse-order", + "logistics:trace:list", ], "manager": [ "order:list", diff --git a/backend/app/services/logistics_service.py b/backend/app/services/logistics_service.py index 9b36ef0..a2f4bfd 100644 --- a/backend/app/services/logistics_service.py +++ b/backend/app/services/logistics_service.py @@ -666,7 +666,11 @@ class LogisticsService: } for item in traces ] - third_party_trace_list = self._load_third_party_traces(order_id, session) + try: + third_party_trace_list = self._load_third_party_traces(order_id, session) + except AppException as exc: + logger.warning("[get_trace] 第三方轨迹查询失败,降级返回内部轨迹: %s", exc) + third_party_trace_list = [] if trace_list or third_party_trace_list: return { "order_id": order_id, @@ -1106,6 +1110,11 @@ class LogisticsService: if task is None or task.driver_id != current_user.get("user_id"): raise AppException(code=ErrorCode.FORBIDDEN, message="仅可访问本人订单轨迹", status_code=403) return + if role_code == "salesman": + order = self.order_repository.get_order(session, order_id) + if order is None or order.salesman_id != current_user.get("user_id"): + raise AppException(code=ErrorCode.FORBIDDEN, message="仅可访问本人订单轨迹", status_code=403) + return raise AppException(code=ErrorCode.FORBIDDEN, message="无权限访问", status_code=403) def _notify_driver_task_assigned(self, session: Session, task, order) -> None: @@ -1257,12 +1266,11 @@ class LogisticsService: session, { "reminder_type": "order_status_change", - "receiver_id": receiver, - "title": "订单状态变更", - "content": reminder_content, + "receiver_user_id": receiver, + "reminder_title": "订单状态变更", + "reminder_content": reminder_content, "biz_type": "sales_order", "biz_id": order.id, - "source_platform": "backend", }, ) @@ -1481,6 +1489,7 @@ class LogisticsService: AppException: 配置不完整或 API 调用失败时抛出 """ import hashlib + from urllib.parse import urlencode as _urlencode settings = self.settings if not settings.kuaidi100_key or not settings.kuaidi100_customer: @@ -1490,36 +1499,33 @@ class LogisticsService: status_code=400, ) + # 快递100 传统查询接口(GET 方式) endpoint = (settings.logistics_trace_endpoint or "https://api.kuaidi100.com").strip().rstrip("/") - path = (settings.logistics_trace_path or "/query").strip() + path = (settings.logistics_trace_path or "/api").strip() if not path.startswith("/"): path = f"/{path}" url = f"{endpoint}{path}" - param = { - "com": express_company, - "num": tracking_number, - "phone": "", - "from": "", - "to": "", - } - # 快递100 签名:MD5(key + 排序参数拼接 + customer + key) - sorted_params = "".join(f"{k}{v}" for k, v in sorted(param.items()) if v) - sign_str = f"{settings.kuaidi100_key}{sorted_params}{settings.kuaidi100_customer}{settings.kuaidi100_key}" + # 签名:MD5(key + 运单号 + 快递公司编码 + customer) + sign_str = f"{settings.kuaidi100_key}{tracking_number}{express_company}{settings.kuaidi100_customer}" sign = hashlib.md5(sign_str.encode("utf-8")).hexdigest() - body = json.dumps({ - "customer": settings.kuaidi100_customer, + params = { "key": settings.kuaidi100_key, + "com": express_company, + "nu": tracking_number, + "show": "0", + "muti": "1", + "order": "desc", + "customer": settings.kuaidi100_customer, "sign": sign, - "param": param, - }, ensure_ascii=False).encode("utf-8") + } + url = f"{url}?{_urlencode(params)}" req = request.Request( url=url, - data=body, - headers={"Content-Type": "application/json; charset=UTF-8"}, - method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="GET", ) try: with request.urlopen(req, timeout=20) as response: @@ -1608,10 +1614,10 @@ class LogisticsService: third_party_trace_list: 第三方轨迹列表 Returns: - 合并后的轨迹列表,按 node_time 升序排列 + 合并后的轨迹列表,按 node_time 降序排列(最新的在前) """ merged = list(local_trace_list) + list(third_party_trace_list) - merged.sort(key=lambda item: item.get("node_time") or "") + merged.sort(key=lambda item: item.get("node_time") or "", reverse=True) return merged diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a91736e..60f0aa4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -311,6 +311,7 @@ def seed_menus(db_session): RoleMenu(role_id=2, menu_id=22), # customer:create RoleMenu(role_id=2, menu_id=23), # customer:update RoleMenu(role_id=2, menu_id=45), # ai:parse-order + RoleMenu(role_id=2, menu_id=28), # logistics:trace:list # 管理层:订单、审批、物流、报表、主数据、AI RoleMenu(role_id=3, menu_id=1), # order:list RoleMenu(role_id=3, menu_id=3), # order:approve diff --git a/frontend/mini-app/pages/driver/task-detail/task-detail.wxml b/frontend/mini-app/pages/driver/task-detail/task-detail.wxml index 9c306a1..cf3fa33 100644 --- a/frontend/mini-app/pages/driver/task-detail/task-detail.wxml +++ b/frontend/mini-app/pages/driver/task-detail/task-detail.wxml @@ -130,13 +130,17 @@ - + + + 揽件完成,等待业务员绑定物流单号 + + 任务已完成 diff --git a/frontend/web-admin/src/views/OrdersPage.vue b/frontend/web-admin/src/views/OrdersPage.vue index 924e183..caecf4a 100644 --- a/frontend/web-admin/src/views/OrdersPage.vue +++ b/frontend/web-admin/src/views/OrdersPage.vue @@ -507,7 +507,20 @@
马甸工厂自发 — 已填写快递单号({{ detail.order.trackingNumber }}),审批通过后跳过工厂下发 +
+ +
+
+
+
+
+ {{ item.node_time }} + {{ item.node_desc }} +
+
+
+

暂无物流轨迹

@@ -897,6 +910,7 @@ import { generateSupplierText, fetchProductOptions, cancelApproveOrder, + approveOrder, updateOrderTracking, updateLogisticsTracking, batchMatchLogisticsPreview, @@ -1445,7 +1459,7 @@ async function loadDetail() { }; detail.items = d.items || []; detail.approveLogs = d.approve_logs || []; - detail.logistics = d.logistics_info?.task || null; + detail.logistics = d.logistics_info?.task?.task_id ? d.logistics_info.task : null; // 物流轨迹:有物流任务单号或订单单号都加载 detail.traceList = []; @@ -1633,11 +1647,11 @@ async function handleApprovalPass() { // 订单审批通过 if (detail.order.trackingNumber) { - // 自发订单:直接完成(跳过工厂和司机) + // 自发订单:调用审批接口,后端会自动转到 in_transit detailActionBusy.value = true; try { - await changeOrderStatus(detail.orderId, {target_status: 'completed'}); - toast.success('马甸工厂自发 — 审批通过,订单已直接完成'); + await approveOrder(detail.orderId, {approve_result: 'pass', approve_opinion: '审批通过'}); + toast.success('马甸工厂自发 — 审批通过,订单已进入运输中'); await loadDetail(); await handleSearch(); } catch (e) { diff --git a/frontend/web-sales/src/views/OrderDetailPage.vue b/frontend/web-sales/src/views/OrderDetailPage.vue index 6a154be..77ecc69 100644 --- a/frontend/web-sales/src/views/OrderDetailPage.vue +++ b/frontend/web-sales/src/views/OrderDetailPage.vue @@ -91,6 +91,16 @@ {{ selfDeliveryTracking }}
+ +
+
+
+
+ {{ item.node_time }} + {{ item.node_desc }} +
+
+
@@ -326,9 +336,13 @@ async function loadDetail() { async function loadTrace() { try { const orderId = route.params.id; - const result = await fetchOrderLogisticsTrace(orderId); - if (result) { - traceList.value = [{ node_time: result.latest_time, node_desc: result.latest_desc }]; + const token = localStorage.getItem(SALES_TOKEN_KEY) || ""; + const res = await fetch(`${apiBaseUrl}/api/logistics/${orderId}/trace`, { + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + }); + const result = await res.json().catch(() => ({})); + if (res.ok && result.code === 0) { + traceList.value = (result.data || {}).trace_list || []; } } catch { // 忽略错误