Merge branch 'claude/update-tracking'

# Conflicts:
#	frontend/web-sales/src/views/OrderDetailPage.vue
This commit is contained in:
taiyi 2026-05-30 17:21:17 +08:00
commit 6788e1f5b4
4 changed files with 191 additions and 1 deletions

View File

@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from backend.app.api.deps import get_logistics_service, require_permissions, require_roles
from backend.app.db import get_db_session
from backend.app.schemas.common import success_payload
from backend.app.schemas.logistics import CreateLogisticsTaskRequest, CreateLogisticsTraceRequest, DriverTaskOperateRequest
from backend.app.schemas.logistics import CreateLogisticsTaskRequest, CreateLogisticsTraceRequest, DriverTaskOperateRequest, UpdateTrackingRequest
from backend.app.services.logistics_service import LogisticsService
router = APIRouter(tags=["logistics"])
@ -113,6 +113,32 @@ def get_logistics_task(
return success_payload(logistics_service.get_task(task_id, session, current_user))
@router.put("/api/logistics/tasks/{task_id}/tracking")
def update_tracking_number(
task_id: int, # 物流任务ID
payload: UpdateTrackingRequest, # 修改物流单号请求体
logistics_service: LogisticsService = Depends(get_logistics_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
) -> dict:
"""
修改物流单号
业务员或管理员修改已有物流任务的快递单号和快递公司
业务员仅可修改自己订单关联的物流任务
请求参数
task_id: 路径参数物流任务ID
payload: UpdateTrackingRequest 请求体
返回值
更新后的物流任务信息
权限要求salesmanmanageradmin 角色
"""
return success_payload(logistics_service.update_tracking(task_id, payload.model_dump(), session, current_user))
@router.get("/api/driver/tasks")
def list_driver_tasks(
logistics_service: LogisticsService = Depends(get_logistics_service), # 物流服务实例

View File

@ -113,3 +113,16 @@ class CreateLogisticsTraceRequest(BaseModel):
source_platform: str
"""来源平台"""
class UpdateTrackingRequest(BaseModel):
"""修改物流单号请求体。被 PUT /api/logistics/tasks/{id}/tracking 路由使用。"""
tracking_number: str
"""新的物流追踪单号"""
express_company: str | None = None
"""快递公司"""
remark: str | None = None
"""修改原因/备注"""

View File

@ -285,6 +285,70 @@ class LogisticsService:
"""
return self.get_task(task_id, session=session, current_user=current_user)
def update_tracking(
self,
task_id: int,
payload: dict,
session: Session | None = None,
current_user: dict | None = None,
) -> dict:
"""修改物流单号。
业务员/管理员修改已有物流任务的快递单号和快递公司
业务员只能修改自己订单关联的任务
Args:
task_id: 物流任务 ID
payload: 包含 tracking_numberexpress_companyremark 的字典
session: 数据库会话
current_user: 当前登录用户信息
Returns:
更新后的任务信息字典
被调用路由: logistics.py - PUT /logistics/tasks/{task_id}/tracking
"""
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
task = self.logistics_repository.get_task(session, task_id)
if task is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="物流任务不存在", status_code=404)
# 权限校验salesman 只能改自己订单的物流单号
if current_user and current_user.get("role_code") == "salesman":
order = self.order_repository.get_order(session, task.order_id)
if order and order.salesman_id != current_user.get("user_id"):
raise AppException(code=ErrorCode.FORBIDDEN, message="无权修改他人订单的物流单号", status_code=403)
new_tracking = (payload.get("tracking_number") or "").strip()
if not new_tracking:
raise AppException(code=ErrorCode.PARAM_ERROR, message="物流单号不能为空", status_code=400)
old_tracking = task.tracking_number or ""
old_express = task.express_company or ""
task.tracking_number = new_tracking
task.express_company = (payload.get("express_company") or "").strip() or None
audit_service.write_log(
session,
{
"operate_type": "tracking_update",
"biz_type": "logistics_task",
"biz_id": task.id,
"before_value": {"tracking_number": old_tracking, "express_company": old_express},
"after_value": {"tracking_number": new_tracking, "express_company": task.express_company},
"remark": payload.get("remark") or f"修改物流单号:{old_tracking}{new_tracking}",
},
)
session.commit()
return {
"task_id": task.id,
"order_id": task.order_id,
"tracking_number": task.tracking_number,
"express_company": task.express_company or "",
}
def accept_task(
self,
task_id: int,

View File

@ -74,6 +74,17 @@
</article>
</div>
<div v-if="!loading && trackingInfo" class="tracking-card">
<div class="tracking-header">
<h3>物流信息</h3>
<button class="link-btn" @click="openTrackingEdit">修改单号</button>
</div>
<div class="tracking-fields">
<span>快递公司<strong>{{ trackingInfo.express_company || '-' }}</strong></span>
<span>快递单号<strong>{{ trackingInfo.tracking_number || '-' }}</strong></span>
</div>
</div>
<section v-if="!loading" class="detail-panel">
<div class="panel-header">
<h3>订单明细</h3>
@ -149,6 +160,28 @@
</div>
</div>
</div>
<div v-if="showTrackingEdit" class="modal-mask" @click.self="showTrackingEdit = false">
<div class="modal-box">
<h3>修改物流单号</h3>
<label class="reason-label">
<span>快递公司</span>
<input v-model.trim="trackingForm.express_company" type="text" placeholder="如:顺丰、圆通" />
</label>
<label class="reason-label">
<span>快递单号</span>
<input v-model.trim="trackingForm.tracking_number" type="text" placeholder="请输入新快递单号" />
</label>
<label class="reason-label">
<span>修改原因</span>
<input v-model.trim="trackingForm.remark" type="text" placeholder="选填" />
</label>
<div class="modal-actions">
<button class="cancel-btn" @click="showTrackingEdit = false">取消</button>
<button class="confirm-btn" :disabled="!trackingForm.tracking_number || trackingSaving" @click="doUpdateTracking">{{ trackingSaving ? '保存中...' : '确认修改' }}</button>
</div>
</div>
</div>
</section>
</template>
@ -157,8 +190,11 @@ import { computed, onMounted, ref } from "vue";
import { RouterLink, useRoute, useRouter } from "vue-router";
import ConfirmModal from "../components/ConfirmModal.vue";
import { apiBaseUrl } from "../config";
import { cancelOrder, fetchOrderDetail, submitOrder } from "../mockApi";
const SALES_TOKEN_KEY = "sales_token";
const route = useRoute();
const router = useRouter();
const loading = ref(true);
@ -175,6 +211,11 @@ const message = ref("");
const messageType = ref("success");
const orderMeta = ref({ customer: "-", logistics: "-", factory: "-" });
const isModal = computed(() => route.query.modal === "1");
const trackingInfo = ref(null);
const showTrackingEdit = ref(false);
const trackingSaving = ref(false);
const trackingForm = reactive({ tracking_number: "", express_company: "", remark: "" });
const currentTaskId = ref(null);
function canSubmit(status) {
return ["draft", "rejected"].includes(status);
@ -208,6 +249,14 @@ async function loadDetail() {
logistics: data.blocks?.[3]?.content || "-",
factory: data.blocks?.[3]?.content || "-",
};
const task = data.logistics_info?.task;
if (task && task.task_id) {
trackingInfo.value = { tracking_number: task.tracking_number || "", express_company: task.express_company || "" };
currentTaskId.value = task.task_id;
} else {
trackingInfo.value = null;
currentTaskId.value = null;
}
}
function handleSubmit() {
@ -274,6 +323,37 @@ async function doCancel() {
}
}
function openTrackingEdit() {
trackingForm.tracking_number = trackingInfo.value?.tracking_number || "";
trackingForm.express_company = trackingInfo.value?.express_company || "";
trackingForm.remark = "";
showTrackingEdit.value = true;
}
async function doUpdateTracking() {
if (!trackingForm.tracking_number || !currentTaskId.value) return;
trackingSaving.value = true;
try {
const token = localStorage.getItem(SALES_TOKEN_KEY) || "";
const res = await fetch(`${apiBaseUrl}/api/logistics/tasks/${currentTaskId.value}/tracking`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ tracking_number: trackingForm.tracking_number, express_company: trackingForm.express_company, remark: trackingForm.remark }),
});
const result = await res.json().catch(() => ({}));
if (!res.ok || result.code !== 0) throw new Error(result.message || "修改失败");
showTrackingEdit.value = false;
await loadDetail();
message.value = "物流单号已更新";
messageType.value = "success";
} catch (error) {
message.value = error.message || "修改物流单号失败";
messageType.value = "error";
} finally {
trackingSaving.value = false;
}
}
function handleClose() {
router.push("/orders");
}
@ -658,4 +738,11 @@ p {
.confirm-btn { background: linear-gradient(135deg, #2563eb, #1d4ed8); color: #fff; border-color: transparent; }
.confirm-btn.danger { background: linear-gradient(135deg, #dc2626, #b91c1c); }
.confirm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.tracking-card { border: 1px solid #dbe3ef; border-radius: 18px; padding: 16px; background: #f0fdf4; }
.tracking-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.tracking-header h3 { margin: 0; font-size: 16px; }
.tracking-fields { display: flex; gap: 24px; color: #475569; }
.tracking-fields strong { color: #1d4ed8; }
.link-btn { background: none; border: none; color: #2563eb; cursor: pointer; font-size: 13px; text-decoration: underline; }
</style>