7-6 订单物流修复

This commit is contained in:
wsb1224 2026-07-06 15:16:27 +08:00
parent ea1c5a7835
commit 8fc64fee8f
12 changed files with 2734 additions and 43 deletions

View File

@ -95,6 +95,7 @@ class AuthService:
"ai:recognize",
"ai:correct",
"ai:parse-order",
"logistics:trace:list",
],
"driver": [
"driver:task:list",
@ -135,6 +136,7 @@ class AuthService:
"ai:recognize",
"ai:correct",
"ai:parse-order",
"logistics:trace:list",
],
"kuaiji": [
"dashboard:view",
@ -154,6 +156,7 @@ class AuthService:
"ai:parse-order",
"reminder:list",
"config:view",
"logistics:trace:list",
],
}

View File

@ -105,6 +105,7 @@ DEFAULT_MENUS = [
{"id": 20, "parent_id": 5, "menu_name": "提交审核", "menu_path": "", "menu_type": "button", "permission_code": "order:submit", "icon": "", "sort_no": 20, "status": 1},
{"id": 21, "parent_id": 5, "menu_name": "取消审批", "menu_path": "", "menu_type": "button", "permission_code": "order:cancel-approve", "icon": "", "sort_no": 21, "status": 1},
{"id": 24, "parent_id": 5, "menu_name": "订单更新", "menu_path": "", "menu_type": "button", "permission_code": "order:update", "icon": "", "sort_no": 24, "status": 1},
{"id": 46, "parent_id": 5, "menu_name": "物流轨迹查看", "menu_path": "", "menu_type": "button", "permission_code": "logistics:trace:list", "icon": "", "sort_no": 46, "status": 1},
# 按钮权限 - 基础资料
{"id": 22, "parent_id": 3, "menu_name": "定价规则编辑", "menu_path": "", "menu_type": "button", "permission_code": "master-data:update", "icon": "", "sort_no": 22, "status": 1},
@ -183,8 +184,11 @@ DEFAULT_ROLE_MENU_CODES = {
"ai:recognize",
"ai:correct",
"ai:parse-order",
"logistics:trace:list",
],
"driver": [
"logistics:trace:list",
],
"driver": [],
"kuaiji": [
"dashboard:view",
"order:list",
@ -203,6 +207,7 @@ DEFAULT_ROLE_MENU_CODES = {
"ai:parse-order",
"reminder:list",
"config:view",
"logistics:trace:list",
],
}

View File

@ -406,11 +406,24 @@ class OrderService:
if len(order_items) > 2:
items_summary += "..."
# 获取物流信息
# 获取物流信息。列表用单号触发前端查询最新轨迹,自发订单回退到 sales_order。
task = self.logistics_repository.get_task_by_order_id(session, item.id)
tracking_number = (item.tracking_number or "").strip()
express_company = (item.express_company or "").strip()
logistics_info = ""
if task:
logistics_info = task.express_company + " " + (task.tracking_number or "") if task.express_company else task.status or ""
task_tracking = (task.tracking_number or "").strip()
task_express = (task.express_company or "").strip()
if not task_tracking:
waybills = self.logistics_repository.list_waybills_by_task_id(session, task.id)
if waybills:
task_tracking = (waybills[0].tracking_number or "").strip()
task_express = (waybills[0].express_company or "").strip()
tracking_number = task_tracking or tracking_number
express_company = task_express or express_company
logistics_info = " ".join(part for part in [express_company, tracking_number] if part) or task.status or ""
elif tracking_number:
logistics_info = " ".join(part for part in [express_company, tracking_number] if part)
row = self._filter_order_row(
{
@ -422,6 +435,7 @@ class OrderService:
"order_status": item.order_status,
"order_source": item.order_source,
"order_type": item.order_type,
"delivery_type": item.delivery_type,
"contract_amount": float(item.contract_amount or 0),
"cost_price_total": float(item.cost_price_total or 0),
"profit_total": float(item.profit_total or 0),
@ -430,6 +444,9 @@ class OrderService:
"created_at": item.created_at.strftime("%Y-%m-%d %H:%M:%S") if item.created_at else "",
"items_summary": items_summary,
"logistics_info": logistics_info,
"tracking_number": tracking_number,
"express_company": express_company,
"self_delivery": item.self_delivery or 0,
},
role_code,
)
@ -2271,6 +2288,7 @@ class OrderService:
express_company = waybills[0].express_company
if not tracking_number and order:
tracking_number = order.tracking_number
express_company = express_company or order.express_company
return {
"task_id": task.id,
"task_no": task.task_no,

View File

@ -0,0 +1,99 @@
"""修正旧订单的 cost_price。
旧代码中 _enrich_items_with_pricing 用定价引擎结果覆盖了前端传入的 cost_price
导致成本被算高此脚本用产品的基础 cost_price 重新计算订单项的成本
用法: cd backend && python -m fix_old_order_costs
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from decimal import Decimal
from sqlalchemy import select
from backend.app.db import get_db_session
from backend.app.models.business import SalesOrder, SalesOrderItem, Product
def _fix_order_totals(session):
"""修正订单级汇总:重新计算 cost_price_total 和 profit_total。"""
orders = session.query(SalesOrder).filter(SalesOrder.deleted == 0).all()
updated = 0
for order in orders:
items = session.query(SalesOrderItem).filter(
SalesOrderItem.order_id == order.id,
SalesOrderItem.deleted == 0,
).all()
if not items:
continue
new_cost_total = sum(float(item.quantity or 0) * float(item.cost_price or 0) for item in items)
new_cost_total = round(new_cost_total, 2)
old_cost_total = float(order.cost_price_total or 0)
if abs(old_cost_total - new_cost_total) > 0.01:
order.cost_price_total = Decimal(str(new_cost_total))
# 重算利润
income = float(order.contract_amount or 0) or float(order.sale_price_total or 0)
profit = income - new_cost_total - float(order.rebate_total or 0) - float(order.freight_total or 0) - float(order.tax_total or 0) - float(order.other_fee_total or 0)
order.profit_total = Decimal(str(round(profit, 2)))
order.profit_rate = Decimal(str(round((profit / income * 100) if income else 0, 2)))
updated += 1
if updated:
session.commit()
print(f"订单级汇总已更新: {updated} 个订单")
def fix_old_orders():
session = next(get_db_session())
try:
# 查找所有有 pricing_type 的订单项(这些是被旧代码 enrichment 过的)
items = session.query(SalesOrderItem).filter(
SalesOrderItem.pricing_type.isnot(None),
SalesOrderItem.deleted == 0,
).all()
if not items:
print("没有需要修正的订单项")
return
print(f"找到 {len(items)} 个需要检查的订单项")
fixed_count = 0
for item in items:
# 获取产品的基础 cost_price
product = session.query(Product).filter(Product.id == item.product_id).first()
if not product:
print(f" 跳过 item {item.id}: 找不到产品 {item.product_id}")
continue
product_cost = float(product.cost_price or 0)
qty = float(item.quantity or 0)
old_cost = float(item.cost_price or 0)
# 正确的成本 = 数量 × 产品基础成本价
correct_cost = round(qty * product_cost, 2)
if abs(old_cost - correct_cost) > 0.01:
print(f" 修正 item {item.id}: 产品={product.product_name}, "
f"旧cost_price={old_cost}, 新cost_price={correct_cost} "
f"(数量={qty} × 产品成本={product_cost})")
item.cost_price = Decimal(str(correct_cost))
fixed_count += 1
if fixed_count > 0:
session.commit()
print(f"\n修正完成: {fixed_count} 个订单项的 cost_price 已更新")
# 重新汇总订单级 cost_price_total
_fix_order_totals(session)
else:
print("\n所有订单项的成本价已经是正确的")
except Exception as e:
session.rollback()
print(f"错误: {e}")
raise
finally:
session.close()
if __name__ == "__main__":
fix_old_orders()

View File

@ -0,0 +1,101 @@
"""快递100自动送达状态测试。
测试根据物流单号查询快递100状态自动更新订单为已送达的逻辑
"""
from __future__ import annotations
from unittest.mock import patch, MagicMock
import pytest
@pytest.mark.logistics
@pytest.mark.p0
class TestAutoDelivery:
"""测试快递100自动送达逻辑。"""
def test_auto_deliver_on_kuaidi100_signed(self, client, manager_headers, make_order):
"""当快递100返回已签收时订单应自动更新为已送达。"""
# 创建订单并设置为 in_transit 状态
order = make_order(order_status="in_transit")
order_id = order.id
# Mock 快递100 API 返回已签收
with patch('backend.app.services.logistics_service.LogisticsService._load_third_party_traces') as mock_load:
mock_load.return_value = [
{
"node_time": "2026-07-03 10:00:00",
"node_desc": "已签收",
"node_type": "signed",
"source_platform": "kuaidi100",
}
]
# 执行自动送达检查
from backend.app.services.logistics_service import LogisticsService
logistics_service = LogisticsService()
from sqlalchemy.orm import Session
# 使用测试数据库会话
with patch('backend.app.services.logistics_service.get_db_session') as mock_session:
mock_session.return_value.__enter__ = MagicMock(return_value=manager_headers)
mock_session.return_value.__exit__ = MagicMock(return_value=False)
# 简化测试:直接调用方法并验证逻辑
pass
# 验证订单状态(需要实际数据库会话)
# detail = client.get(f"/api/orders/{order_id}", headers=manager_headers)
# assert detail.json()["data"]["order_status"] == "delivered"
def test_auto_deliver_logic_with_mock(self, make_order, db_session):
"""使用mock测试自动送达逻辑。"""
from backend.app.services.logistics_service import LogisticsService
from backend.app.models.business import SalesOrder
# 创建订单并设置为 in_transit 状态
order = make_order(order_status="in_transit")
order_id = order.id
# Mock 快递100 API 返回已签收
with patch.object(LogisticsService, '_load_third_party_traces') as mock_load:
mock_load.return_value = [
{
"node_time": "2026-07-03 10:00:00",
"node_desc": "已签收",
"node_type": "signed",
"source_platform": "kuaidi100",
}
]
# Mock 更新订单状态的方法
with patch.object(LogisticsService, '_update_order_to_delivered') as mock_update:
logistics_service = LogisticsService()
logistics_service.check_delivery_by_tracking(db_session)
# 验证 _update_order_to_delivered 被调用
mock_update.assert_called_once()
def test_auto_deliver_not_triggered_when_no_signed_status(self, make_order, db_session):
"""当快递100返回非已签收状态时不应触发自动送达。"""
from backend.app.services.logistics_service import LogisticsService
# 创建订单并设置为 in_transit 状态
order = make_order(order_status="in_transit")
order_id = order.id
# Mock 快递100 API 返回在途中状态
with patch.object(LogisticsService, '_load_third_party_traces') as mock_load:
mock_load.return_value = [
{
"node_time": "2026-07-03 10:00:00",
"node_desc": "在途中",
"node_type": "transit",
"source_platform": "kuaidi100",
}
]
# Mock 更新订单状态的方法
with patch.object(LogisticsService, '_update_order_to_delivered') as mock_update:
logistics_service = LogisticsService()
logistics_service.check_delivery_by_tracking(db_session)
# 验证 _update_order_to_delivered 未被调用
mock_update.assert_not_called()

View File

@ -14,6 +14,29 @@ from __future__ import annotations
import pytest
@pytest.mark.order
@pytest.mark.p1
class TestOrderListLogistics:
def test_list_includes_order_tracking_without_task(self, client, salesman_headers, make_order):
order = make_order(
order_status="in_transit",
salesman_id=2,
tracking_number="SF1234567890",
express_company="shunfeng",
self_delivery=1,
)
resp = client.get(f"/api/orders?order_no={order.order_no}", headers=salesman_headers)
assert resp.status_code == 200
rows = resp.json()["data"]["list"]
assert len(rows) == 1
assert rows[0]["tracking_number"] == "SF1234567890"
assert rows[0]["express_company"] == "shunfeng"
assert "SF1234567890" in rows[0]["logistics_info"]
assert rows[0]["self_delivery"] == 1
@pytest.mark.order
@pytest.mark.p0
class TestOrderCreation:

View File

@ -24,6 +24,30 @@
</view>
</view>
</view>
<!-- 订单基础信息 -->
<view class="card info-card">
<view class="section-title">订单信息</view>
<view class="info-row" wx:if="{{orderInfo.salesman_name}}">
<text class="info-label">业务员</text>
<text class="info-value">{{orderInfo.salesman_name}}</text>
</view>
<!-- <view class="info-row" wx:if="{{orderInfo.delivery_type}}">
<text class="info-label">交付方式</text>
<text class="info-value">{{orderInfo.delivery_type}}</text>
</view> -->
<view class="info-row" wx:if="{{orderInfo.factory_name}}">
<text class="info-label">工厂</text>
<text class="info-value">{{orderInfo.factory_name}}</text>
</view>
<view class="info-row" wx:if="{{orderInfo.tracking_number}}">
<text class="info-label">快递单号</text>
<text class="info-value">{{orderInfo.tracking_number}}</text>
</view>
<view class="info-row" wx:if="{{orderInfo.created_at}}">
<text class="info-label">创建时间</text>
<text class="info-value">{{orderInfo.created_at}}</text>
</view>
</view>
<!-- 客户收货信息 -->
<view class="card info-card">
@ -42,31 +66,7 @@
</view>
</view>
<!-- 订单基础信息 -->
<view class="card info-card">
<view class="section-title">订单信息</view>
<view class="info-row" wx:if="{{orderInfo.salesman_name}}">
<text class="info-label">业务员</text>
<text class="info-value">{{orderInfo.salesman_name}}</text>
</view>
<view class="info-row" wx:if="{{orderInfo.delivery_type}}">
<text class="info-label">交付方式</text>
<text class="info-value">{{orderInfo.delivery_type}}</text>
</view>
<view class="info-row" wx:if="{{orderInfo.factory_name}}">
<text class="info-label">工厂</text>
<text class="info-value">{{orderInfo.factory_name}}</text>
</view>
<view class="info-row" wx:if="{{orderInfo.tracking_number}}">
<text class="info-label">快递单号</text>
<text class="info-value">{{orderInfo.tracking_number}}</text>
</view>
<view class="info-row" wx:if="{{orderInfo.created_at}}">
<text class="info-label">创建时间</text>
<text class="info-value">{{orderInfo.created_at}}</text>
</view>
</view>
<!-- 客户需求 + 备注 -->
<view wx:if="{{orderInfo.customer_demand || orderInfo.remark}}" class="card info-card">
<view wx:if="{{orderInfo.customer_demand}}" class="info-row">

View File

@ -979,6 +979,9 @@ export async function fetchOrderList(filters = {}) {
orderSource: item.order_source || "-",
orderType: item.order_type || "",
deliveryType: item.delivery_type || "-",
logisticsInfo: item.logistics_info || item.tracking_number || "-",
trackingNumber: item.tracking_number || "",
expressCompany: item.express_company || "",
contractAmount: Number(item.contract_amount || 0).toFixed(2),
costPriceTotal: Number(item.cost_price_total || 0).toFixed(2),
profitTotal: item.profit_total != null ? Number(item.profit_total).toFixed(2) : null,

View File

@ -85,6 +85,7 @@
<th>客户</th>
<th>订单来源</th>
<th>交付方式</th>
<th>物流信息</th>
<th>状态</th>
<th>合同金额</th>
<th>成本金额</th>
@ -98,6 +99,7 @@
<td>{{ row.customerName }}</td>
<td>{{ row.orderSource }}</td>
<td>{{ row.deliveryType }}</td>
<td :class="{ 'logistics-timeout': row.logisticsTimeout }">{{ row.logisticsLatest || row.logisticsInfo || '-' }}</td>
<td><span class="status-dot" :class="'status-' + row.orderStatus"></span><span
class="status">{{ row.statusText }}</span></td>
<td class="money">{{ row.contractAmount }}</td>
@ -483,8 +485,34 @@
<p v-else-if="detail.logistics.tracking_number" class="doc-empty-hint">暂无物流轨迹</p>
</div>
<!-- 自发订单快递单号无物流任务时显示 -->
<div v-if="!detail.logistics && detail.order.trackingNumber" class="doc-section doc-section-standalone">
<div class="doc-section-title" style="display: flex; justify-content: space-between; align-items: center;">
<span>快递单号</span>
<button v-if="canEditTrackingNumber(detail.order.rawStatus)" class="link-btn" @click="openSelfTrackingEdit" style="font-size: 12px;">修改单号</button>
</div>
<div class="doc-field-grid">
<div class="doc-field">
<label>快递单号</label>
<span>{{ detail.order.trackingNumber }}</span>
</div>
</div>
<div v-if="detail.traceList.length" class="doc-trace-timeline">
<div v-for="(item, index) in detail.traceList" :key="index" class="doc-trace-item">
<div class="doc-trace-dot" :class="{ active: index === 0 }"></div>
<div v-if="index < detail.traceList.length - 1" class="doc-trace-line"></div>
<div class="doc-trace-card">
<span class="doc-trace-time">{{ item.node_time }}</span>
<span class="doc-trace-desc">{{ item.node_desc }}</span>
<span v-if="item.source_platform" class="doc-trace-source">{{ mapSourcePlatform(item.source_platform) }}</span>
</div>
</div>
</div>
<p v-else class="doc-empty-hint" style="margin-top: 8px;">暂无物流轨迹</p>
</div>
<!-- 待绑定物流单号提示无物流任务时允许直接填写快递单号 -->
<div v-if="detail.order.rawStatus === 'pending_logistics' && !detail.logistics" class="doc-section doc-section-standalone">
<div v-if="detail.order.rawStatus === 'pending_logistics' && !detail.logistics && !detail.order.trackingNumber" class="doc-section doc-section-standalone">
<div class="doc-section-title">物流单号</div>
<p class="doc-empty-hint" style="margin-bottom: 12px;">订单已审批通过当前待绑定物流单号请填写快递信息后进入运输状态</p>
<button class="primary-btn" @click="openPendingTrackingEdit">填写快递单号</button>
@ -524,19 +552,6 @@
马甸工厂自发 已填写快递单号{{ detail.order.trackingNumber }}审批通过后跳过工厂下发
<button v-if="canEditTrackingNumber(detail.order.rawStatus)" class="link-btn" @click="openSelfTrackingEdit" style="margin-left: 8px; font-size: 12px;">修改单号</button>
</div>
<!-- 自发订单物流轨迹 -->
<div v-if="detail.order.trackingNumber && detail.traceList.length" class="doc-trace-timeline" style="margin-top: 12px;">
<div v-for="(item, index) in detail.traceList" :key="index" class="doc-trace-item">
<div class="doc-trace-dot" :class="{ active: index === 0 }"></div>
<div v-if="index < detail.traceList.length - 1" class="doc-trace-line"></div>
<div class="doc-trace-card">
<span class="doc-trace-time">{{ item.node_time }}</span>
<span class="doc-trace-desc">{{ item.node_desc }}</span>
</div>
</div>
</div>
<p v-else-if="detail.order.trackingNumber" class="doc-empty-hint" style="margin-top: 8px;">暂无物流轨迹</p>
<!-- 下发工厂选择非自发订单 -->
<div v-if="!detail.order.selfDelivery" class="approval-factory">
<label class="approval-label">下发工厂</label>
@ -1014,6 +1029,7 @@ async function handleSearch() {
orders.value = data.rows;
pagination.total = data.total || data.rows?.length || 0;
if (data.stats) stats.value = data.stats;
enrichLogisticsTrace();
} catch (error) {
toast.error(error.message || '加载订单列表失败');
} finally {
@ -1021,6 +1037,46 @@ async function handleSearch() {
}
}
async function enrichLogisticsTrace() {
const rows = orders.value;
const tasks = rows
.filter(row => row.orderId && row.trackingNumber)
.map(async row => {
const trace = await fetchLatestLogisticsTrace(row.orderId);
if (trace) {
row.logisticsLatest = trace.latestDesc;
row.logisticsTimeout = trace.isTimeout;
}
});
await Promise.allSettled(tasks);
}
async function fetchLatestLogisticsTrace(orderId) {
try {
const resp = await fetch(`${API_BASE}/api/logistics/${orderId}/trace`, {
headers: {'Content-Type': 'application/json', ...(getToken() ? {Authorization: `Bearer ${getToken()}`} : {})},
});
const result = await resp.json();
if (!resp.ok || result.code !== 0) return null;
const traceList = (result.data || {}).trace_list || [];
if (!traceList.length) return null;
const latest = traceList[0];
return {
latestDesc: latest.node_desc || '',
isTimeout: isLogisticsTimeout(latest.node_time || ''),
};
} catch {
return null;
}
}
function isLogisticsTimeout(timeStr) {
if (!timeStr) return false;
const nodeTime = new Date(String(timeStr).replace(' ', 'T')).getTime();
if (Number.isNaN(nodeTime)) return false;
return Date.now() - nodeTime > 48 * 3600 * 1000;
}
function handleReset() {
filters.order_no = '';
filters.customer_name = '';
@ -2267,6 +2323,11 @@ td {
color: #0f172a;
}
.logistics-timeout {
color: #dc2626;
font-weight: 600;
}
.actions {
display: flex;
gap: 8px;

View File

@ -230,6 +230,8 @@ export async function fetchOrdersPage(filters = {}) {
createdAt: item.created_at || "-",
itemsSummary: item.items_summary || "-",
logisticsInfo: item.logistics_info || "-",
trackingNumber: item.tracking_number || "",
expressCompany: item.express_company || "",
})),
isMock: false,
};

View File

@ -339,7 +339,7 @@ async function loadOrders() {
async function enrichLogisticsTrace() {
const rows = orderRows.value;
const tasks = rows
.filter((row) => row.orderId && row.logisticsInfo !== "-")
.filter((row) => row.orderId && (row.trackingNumber || row.logisticsInfo !== "-"))
.map(async (row) => {
const trace = await fetchOrderLogisticsTrace(row.orderId);
if (trace) {

2376
order_flowfn73.sql Normal file

File diff suppressed because it is too large Load Diff