新增订单管理页批量匹配物流功能,支持文本/表格截图/快递面单三种模式
- 文本粘贴:正则解析"快递公司+单号+收件人"多行文本 - 物流表格截图:OCR识别后用LLM提取快递单号和收件人 - 快递面单照片:复用快递100面单OCR接口识别单号 - 按customer_name精确匹配订单,同名时展开选择 - 已有单号标记提示,确认后覆盖 - 含唯一性检查、审计日志、缓存清理 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1d12d061e0
commit
a39bd4bc09
@ -16,6 +16,8 @@ from backend.app.db import get_db_session
|
|||||||
from backend.app.schemas.common import success_payload
|
from backend.app.schemas.common import success_payload
|
||||||
from backend.app.schemas.orders import (
|
from backend.app.schemas.orders import (
|
||||||
ApproveOrderRequest,
|
ApproveOrderRequest,
|
||||||
|
BatchMatchConfirmRequest,
|
||||||
|
BatchMatchLogisticsRequest,
|
||||||
CancelOrderRequest,
|
CancelOrderRequest,
|
||||||
ChangeOrderStatusRequest,
|
ChangeOrderStatusRequest,
|
||||||
ConfirmSupplierTextRequest,
|
ConfirmSupplierTextRequest,
|
||||||
@ -394,3 +396,59 @@ def update_order_tracking(
|
|||||||
if not order:
|
if not order:
|
||||||
raise AppException(code=ErrorCode.NOT_FOUND, message="订单不存在", status_code=404)
|
raise AppException(code=ErrorCode.NOT_FOUND, message="订单不存在", status_code=404)
|
||||||
return success_payload(order)
|
return success_payload(order)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-match-logistics")
|
||||||
|
def batch_match_logistics(
|
||||||
|
payload: BatchMatchLogisticsRequest,
|
||||||
|
order_service: OrderService = Depends(get_order_service),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
current_user: dict = Depends(require_roles("manager", "admin")),
|
||||||
|
_permission_user: dict = Depends(require_permissions("order:update")),
|
||||||
|
) -> dict:
|
||||||
|
"""批量匹配物流预览:解析文本/图片,返回物流条目及匹配的订单候选。"""
|
||||||
|
# 1. 根据模式解析物流条目
|
||||||
|
if payload.mode == "text":
|
||||||
|
if not payload.text or not payload.text.strip():
|
||||||
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="文本内容不能为空", status_code=400)
|
||||||
|
items = order_service.parse_logistics_text(payload.text)
|
||||||
|
elif payload.mode == "image_table":
|
||||||
|
if not payload.image_urls:
|
||||||
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="请上传物流表格图片", status_code=400)
|
||||||
|
items = order_service.parse_logistics_table_images(payload.image_urls, session)
|
||||||
|
elif payload.mode == "express_images":
|
||||||
|
if not payload.image_urls:
|
||||||
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="请上传快递面单照片", status_code=400)
|
||||||
|
items = order_service.parse_express_images(payload.image_urls, session)
|
||||||
|
else:
|
||||||
|
raise AppException(code=ErrorCode.PARAM_ERROR, message=f"不支持的模式: {payload.mode}", status_code=400)
|
||||||
|
|
||||||
|
# 2. 对有收件人的条目自动匹配订单
|
||||||
|
for item in items:
|
||||||
|
recipient = item.get("recipient_name")
|
||||||
|
if recipient:
|
||||||
|
candidates = order_service._match_orders_by_name(recipient, session)
|
||||||
|
item["candidates"] = candidates
|
||||||
|
else:
|
||||||
|
item["candidates"] = []
|
||||||
|
|
||||||
|
return success_payload({"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-match-logistics/confirm")
|
||||||
|
def batch_match_logistics_confirm(
|
||||||
|
payload: BatchMatchConfirmRequest,
|
||||||
|
order_service: OrderService = Depends(get_order_service),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
current_user: dict = Depends(require_roles("manager", "admin")),
|
||||||
|
_permission_user: dict = Depends(require_permissions("order:update")),
|
||||||
|
) -> dict:
|
||||||
|
"""批量确认物流匹配结果,将快递单号写入订单。"""
|
||||||
|
matches = [m.model_dump() for m in payload.matches]
|
||||||
|
# 过滤掉未选择订单的条目(order_id 为 null)
|
||||||
|
valid_matches = [m for m in matches if m.get("order_id")]
|
||||||
|
if not valid_matches:
|
||||||
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="请至少选择一条匹配记录", status_code=400)
|
||||||
|
result = order_service.confirm_batch_match(valid_matches, session)
|
||||||
|
session.commit()
|
||||||
|
return success_payload(result)
|
||||||
|
|||||||
@ -307,4 +307,39 @@ class UpdateOrderTrackingRequest(BaseModel):
|
|||||||
"""快递单号"""
|
"""快递单号"""
|
||||||
|
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
"""修改原因"""
|
|
||||||
|
|
||||||
|
class LogisticsMatchItem(BaseModel):
|
||||||
|
"""批量匹配物流条目。"""
|
||||||
|
|
||||||
|
tracking_number: str
|
||||||
|
"""快递单号"""
|
||||||
|
|
||||||
|
express_company: str | None = None
|
||||||
|
"""快递公司"""
|
||||||
|
|
||||||
|
recipient_name: str | None = None
|
||||||
|
"""收件人姓名(面单场景可能为空)"""
|
||||||
|
|
||||||
|
order_id: int | None = None
|
||||||
|
"""用户确认后填入的目标订单 ID"""
|
||||||
|
|
||||||
|
|
||||||
|
class BatchMatchLogisticsRequest(BaseModel):
|
||||||
|
"""批量匹配物流请求体。被 POST /api/orders/batch-match-logistics 路由使用。"""
|
||||||
|
|
||||||
|
mode: str
|
||||||
|
"""模式:text(文本粘贴)| image_table(物流表格截图)| express_images(快递面单照片)"""
|
||||||
|
|
||||||
|
text: str | None = None
|
||||||
|
"""mode=text 时的文本内容"""
|
||||||
|
|
||||||
|
image_urls: list[str] = Field(default_factory=list)
|
||||||
|
"""mode=image_table 或 express_images 时的图片 URL 列表"""
|
||||||
|
|
||||||
|
|
||||||
|
class BatchMatchConfirmRequest(BaseModel):
|
||||||
|
"""批量匹配确认请求体。被 POST /api/orders/batch-match-logistics/confirm 路由使用。"""
|
||||||
|
|
||||||
|
matches: list[LogisticsMatchItem] = Field(min_length=1)
|
||||||
|
"""用户确认后的匹配列表,至少一条"""
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@ -1809,5 +1810,224 @@ class OrderService:
|
|||||||
f"请安排生产与发货。"
|
f"请安排生产与发货。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 批量匹配物流
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TRACKING_RE = re.compile(r'(?<!\d)(\d{10,20})(?!\d)')
|
||||||
|
_RECIPIENT_PREFIX_RE = re.compile(r'收件人\s*[::]?\s*')
|
||||||
|
|
||||||
|
def parse_logistics_text(self, text):
|
||||||
|
results = []
|
||||||
|
for line in text.strip().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
tracking_match = self._TRACKING_RE.search(line)
|
||||||
|
if not tracking_match:
|
||||||
|
continue
|
||||||
|
tracking_number = tracking_match.group(1)
|
||||||
|
before = line[:tracking_match.start()].strip()
|
||||||
|
after = line[tracking_match.end():].strip()
|
||||||
|
express_company = before if before else None
|
||||||
|
recipient_name = self._RECIPIENT_PREFIX_RE.sub('', after).strip()
|
||||||
|
recipient_name = recipient_name.strip('::.。、 ') or None
|
||||||
|
results.append({
|
||||||
|
"tracking_number": tracking_number,
|
||||||
|
"express_company": express_company,
|
||||||
|
"recipient_name": recipient_name,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def parse_logistics_table_images(self, image_urls, session):
|
||||||
|
"""识别物流表格截图,用 LLM 从 OCR 文本中提取快递单号和收件人。"""
|
||||||
|
from backend.app.services.ai_service import ai_service
|
||||||
|
from backend.app.core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
results = []
|
||||||
|
seen_tracking = set()
|
||||||
|
for url in image_urls:
|
||||||
|
try:
|
||||||
|
ctx = ai_service._ocr_and_parse_image(url)
|
||||||
|
raw_text = ctx.get("raw_text", "")
|
||||||
|
if not raw_text.strip():
|
||||||
|
continue
|
||||||
|
# 用 LLM 从表格文本中提取快递单号和收件人
|
||||||
|
llm_items = self._llm_extract_logistics(raw_text, settings)
|
||||||
|
for item in llm_items:
|
||||||
|
tn = (item.get("tracking_number") or "").strip()
|
||||||
|
if not tn or len(tn) < 10 or tn in seen_tracking:
|
||||||
|
continue
|
||||||
|
seen_tracking.add(tn)
|
||||||
|
results.append({
|
||||||
|
"tracking_number": tn,
|
||||||
|
"express_company": item.get("express_company") or None,
|
||||||
|
"recipient_name": item.get("recipient_name") or None,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _llm_extract_logistics(self, text, settings):
|
||||||
|
"""调用 LLM 从物流表格文本中提取快递单号和收件人。"""
|
||||||
|
import json as _json
|
||||||
|
from urllib import request as urllib_request
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"你是物流信息提取助手。从以下物流表格文本中提取每一行的快递单号和收件人姓名。\n"
|
||||||
|
"表格通常包含:运单号、运单状态、件数、体积、计费重量、付款方式、运费、"
|
||||||
|
"保价费、包装服务费、信息费、代收货款、签收费、声明价值、产品类型、"
|
||||||
|
"增值服务、服务方式、业务属性、托寄物、寄件人、收件人、目的网点 等列。\n\n"
|
||||||
|
"严格按以下 JSON 数组格式输出,不要添加任何其他内容:\n"
|
||||||
|
'[{"tracking_number":"运单号","express_company":"快递公司名或null","recipient_name":"收件人姓名"}]'
|
||||||
|
)
|
||||||
|
payload = _json.dumps({
|
||||||
|
"model": settings.llm_parse_model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": f"请从以下物流表格文本中提取快递单号和收件人:\n\n{text}"},
|
||||||
|
],
|
||||||
|
"temperature": 0.1,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
api_key = settings.llm_parse_api_key or settings.aliyun_ai_access_key_id
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
}
|
||||||
|
req = urllib_request.Request(
|
||||||
|
url=settings.llm_parse_api_url, data=payload, headers=headers, method="POST"
|
||||||
|
)
|
||||||
|
with urllib_request.urlopen(req, timeout=30) as resp:
|
||||||
|
result = _json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
content = result["choices"][0]["message"]["content"]
|
||||||
|
return self._extract_json_array(content)
|
||||||
|
|
||||||
|
def _extract_json_array(self, text):
|
||||||
|
"""从 LLM 响应中提取 JSON 数组。"""
|
||||||
|
import json as _json
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("\n", 1)[1]
|
||||||
|
text = text.rsplit("```", 1)[0]
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("json"):
|
||||||
|
text = text[4:].strip()
|
||||||
|
try:
|
||||||
|
data = _json.loads(text)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
# 尝试截断到最后一个 ]
|
||||||
|
last_bracket = text.rfind("]")
|
||||||
|
while last_bracket > 0:
|
||||||
|
try:
|
||||||
|
return _json.loads(text[:last_bracket + 1])
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
last_bracket = text.rfind("]", 0, last_bracket - 1)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def parse_express_images(self, image_urls, session):
|
||||||
|
"""Parse express waybill photos via kuaidi100 OCR."""
|
||||||
|
from backend.app.services.logistics_service import logistics_service
|
||||||
|
results = []
|
||||||
|
for url in image_urls:
|
||||||
|
try:
|
||||||
|
result = logistics_service.recognize_waybill({"image_url": url}, session)
|
||||||
|
tracking_number = result.get("tracking_number") or result.get("no") or None
|
||||||
|
if not tracking_number:
|
||||||
|
continue
|
||||||
|
express_company = result.get("express_company") or result.get("com") or None
|
||||||
|
results.append({
|
||||||
|
"tracking_number": tracking_number,
|
||||||
|
"express_company": express_company,
|
||||||
|
"recipient_name": None,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _match_orders_by_name(self, name, session):
|
||||||
|
"""Match orders by exact customer_name."""
|
||||||
|
from backend.app.models.business import SalesOrder
|
||||||
|
orders = session.query(SalesOrder).filter(
|
||||||
|
SalesOrder.customer_name == name,
|
||||||
|
SalesOrder.deleted == 0,
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"order_id": o.id,
|
||||||
|
"order_no": o.order_no,
|
||||||
|
"customer_name": o.customer_name,
|
||||||
|
"order_status": o.order_status,
|
||||||
|
"tracking_number": o.tracking_number,
|
||||||
|
}
|
||||||
|
for o in orders
|
||||||
|
]
|
||||||
|
|
||||||
|
def confirm_batch_match(self, matches, session):
|
||||||
|
"""批量确认物流匹配结果,为订单写入快递单号。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
matches: 匹配结果列表,每项包含 order_id 和 tracking_number
|
||||||
|
session: 数据库会话
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含 success_count、fail_count 和 failures 的结果字典
|
||||||
|
"""
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
for m in matches:
|
||||||
|
order_id = m.get("order_id")
|
||||||
|
tracking_number = (m.get("tracking_number") or "").strip()
|
||||||
|
if not tracking_number:
|
||||||
|
fail_count += 1
|
||||||
|
failures.append({"tracking_number": tracking_number, "order_id": order_id, "reason": "快递单号为空"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
order = session.query(SalesOrder).filter(
|
||||||
|
SalesOrder.id == order_id, SalesOrder.deleted == 0
|
||||||
|
).first()
|
||||||
|
if order is None:
|
||||||
|
fail_count += 1
|
||||||
|
failures.append({"tracking_number": tracking_number, "order_id": order_id, "reason": "订单不存在"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 唯一性检查:其他未删除订单是否已占用该快递单号
|
||||||
|
conflict = session.query(SalesOrder).filter(
|
||||||
|
SalesOrder.tracking_number == tracking_number,
|
||||||
|
SalesOrder.id != order_id,
|
||||||
|
SalesOrder.deleted == 0,
|
||||||
|
).first()
|
||||||
|
if conflict:
|
||||||
|
fail_count += 1
|
||||||
|
failures.append({"tracking_number": tracking_number, "order_id": order_id, "reason": f"快递单号已绑定订单 {conflict.order_no}"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_tracking = order.tracking_number or ""
|
||||||
|
order.tracking_number = tracking_number
|
||||||
|
audit_service.write_log(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"operate_type": "order_tracking_batch_update",
|
||||||
|
"biz_type": "sales_order",
|
||||||
|
"biz_id": order.id,
|
||||||
|
"before_value": {"tracking_number": old_tracking},
|
||||||
|
"after_value": {"tracking_number": tracking_number},
|
||||||
|
"remark": f"批量匹配快递单号:{old_tracking} -> {tracking_number}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._invalidate_order_cache(order_id)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
fail_count += 1
|
||||||
|
failures.append({"tracking_number": tracking_number, "order_id": order_id, "reason": str(e)})
|
||||||
|
return {"success_count": success_count, "fail_count": fail_count, "failures": failures}
|
||||||
|
|
||||||
|
|
||||||
order_service = OrderService()
|
order_service = OrderService()
|
||||||
|
|||||||
@ -1374,6 +1374,32 @@ export async function cancelApproveOrder(orderId, payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 批量匹配物流 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量匹配物流预览(解析文本/图片,返回物流条目+匹配的订单)
|
||||||
|
* @param {Object} payload - { mode: "text"|"image_table"|"express_images", text?: string, image_urls?: string[] }
|
||||||
|
* @returns {Promise<{items: Array}>} 解析出的物流条目列表,每条含候选订单
|
||||||
|
*/
|
||||||
|
export async function batchMatchLogisticsPreview(payload) {
|
||||||
|
return request("/api/orders/batch-match-logistics", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认批量匹配(将快递单号写入订单)
|
||||||
|
* @param {Array} matches - [{ tracking_number, express_company?, recipient_name?, order_id }]
|
||||||
|
* @returns {Promise<{success_count: number, fail_count: number, failures: Array}>}
|
||||||
|
*/
|
||||||
|
export async function batchMatchLogisticsConfirm(matches) {
|
||||||
|
return request("/api/orders/batch-match-logistics/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ matches }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 报表统计 ==========
|
// ========== 报表统计 ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -23,8 +23,11 @@
|
|||||||
<!-- 订单列表与筛选 -->
|
<!-- 订单列表与筛选 -->
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3>订单列表</h3>
|
<div>
|
||||||
<span>按条件筛选订单</span>
|
<h3>订单列表</h3>
|
||||||
|
<span>按条件筛选订单</span>
|
||||||
|
</div>
|
||||||
|
<button class="primary-btn" @click="openBatchMatchModal">批量匹配物流</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 筛选工具栏 -->
|
<!-- 筛选工具栏 -->
|
||||||
<section class="toolbar toolbar-4">
|
<section class="toolbar toolbar-4">
|
||||||
@ -659,8 +662,122 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量匹配物流弹窗 -->
|
||||||
|
<div v-if="showBatchMatchModal" class="modal-mask">
|
||||||
|
<section class="modal-panel" style="max-width: 800px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h4>批量匹配物流</h4>
|
||||||
|
<span>将快递单号批量绑定到订单</span>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" @click="showBatchMatchModal = false">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 警告框 -->
|
||||||
|
<div style="padding: 12px 16px; margin-bottom: 16px; background: #fef2f2; color: #b91c1c; border-radius: 10px; font-size: 13px; font-weight: 600; border: 1px solid #fecaca;">
|
||||||
|
注意:批量匹配快递单号后,待审批状态的订单审批通过将跳过工厂/物流环节直接完成(自发订单逻辑)。请确认匹配的订单状态符合预期。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 切换 -->
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||||
|
<button :class="batchMatchTab === 'text' ? 'primary-btn' : 'ghost-btn'" @click="batchMatchTab = 'text'">文本粘贴</button>
|
||||||
|
<button :class="batchMatchTab === 'image_table' ? 'primary-btn' : 'ghost-btn'" @click="batchMatchTab = 'image_table'">物流表格截图</button>
|
||||||
|
<button :class="batchMatchTab === 'express_images' ? 'primary-btn' : 'ghost-btn'" @click="batchMatchTab = 'express_images'">快递面单照片</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文本粘贴 Tab -->
|
||||||
|
<div v-if="batchMatchTab === 'text'">
|
||||||
|
<label class="form-label">
|
||||||
|
<span>粘贴物流文本(每行一条:快递公司 单号 收件人)</span>
|
||||||
|
<textarea v-model="batchMatchText" rows="8" placeholder="汇森速运 800121702450 马静 壹米滴答 113040495943 收件人辜益华 壹米滴答113040450094 收件人:段盼盼" style="font-family: monospace; font-size: 13px;"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 物流表格截图 Tab -->
|
||||||
|
<div v-if="batchMatchTab === 'image_table'">
|
||||||
|
<label class="form-label">
|
||||||
|
<span>上传物流表格截图</span>
|
||||||
|
<input type="file" accept="image/*" @change="handleBatchMatchTableImage" />
|
||||||
|
</label>
|
||||||
|
<p v-if="batchMatchTableImageName" style="font-size: 13px; color: #64748b; margin-top: 4px;">已选择: {{ batchMatchTableImageName }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快递面单照片 Tab -->
|
||||||
|
<div v-if="batchMatchTab === 'express_images'">
|
||||||
|
<label class="form-label">
|
||||||
|
<span>上传快递面单照片(支持多选)</span>
|
||||||
|
<input type="file" accept="image/*" multiple @change="handleBatchMatchExpressImages" />
|
||||||
|
</label>
|
||||||
|
<p v-if="batchMatchExpressImageCount > 0" style="font-size: 13px; color: #64748b; margin-top: 4px;">已选择 {{ batchMatchExpressImageCount }} 张图片</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 识别按钮 -->
|
||||||
|
<div style="margin: 16px 0; display: flex; gap: 10px;">
|
||||||
|
<button class="primary-btn" :disabled="batchMatchRecognizing" @click="handleBatchMatchRecognize">
|
||||||
|
{{ batchMatchRecognizing ? '识别中...' : '识别' }}
|
||||||
|
</button>
|
||||||
|
<button v-if="batchMatchResults.length" class="ghost-btn" @click="batchMatchResults = []">清空结果</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 识别结果预览表 -->
|
||||||
|
<div v-if="batchMatchResults.length" class="table-wrap" style="margin-top: 12px;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>快递单号</th>
|
||||||
|
<th>快递公司</th>
|
||||||
|
<th>收件人</th>
|
||||||
|
<th>匹配订单</th>
|
||||||
|
<th>已有单号</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, idx) in batchMatchResults" :key="idx">
|
||||||
|
<td style="font-family: monospace; font-size: 13px;">{{ item.tracking_number }}</td>
|
||||||
|
<td>{{ item.express_company || '-' }}</td>
|
||||||
|
<td>{{ item.recipient_name || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<select v-model="item.order_id" style="min-width: 200px;">
|
||||||
|
<option :value="null">请选择订单</option>
|
||||||
|
<option v-for="c in item.candidates" :key="c.order_id" :value="c.order_id">
|
||||||
|
{{ c.order_no }} - {{ c.customer_name }} ({{ statusTextMap[c.order_status] || c.order_status }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="item.candidates.find(c => c.order_id === item.order_id && c.tracking_number)" style="color: #d97706; font-size: 12px;">
|
||||||
|
{{ item.candidates.find(c => c.order_id === item.order_id).tracking_number }}
|
||||||
|
</span>
|
||||||
|
<span v-else style="color: #9ca3af;">-</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="link-btn danger-link" @click="batchMatchResults.splice(idx, 1)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示信息 -->
|
||||||
|
<p v-if="batchMatchResults.length && !batchMatchResults.some(r => r.order_id)" style="color: #d97706; font-size: 13px; margin-top: 8px;">
|
||||||
|
请为每条物流信息选择对应的订单
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="action-row split" style="margin-top: 16px;">
|
||||||
|
<button class="ghost-btn" @click="showBatchMatchModal = false">取消</button>
|
||||||
|
<button
|
||||||
|
class="primary-btn"
|
||||||
|
:disabled="batchMatchConfirming || !batchMatchResults.some(r => r.order_id)"
|
||||||
|
@click="handleBatchMatchConfirm"
|
||||||
|
>
|
||||||
|
{{ batchMatchConfirming ? '提交中...' : '确认匹配' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
@ -670,7 +787,7 @@
|
|||||||
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useToast } from '../composables/useToast';
|
import { useToast } from '../composables/useToast';
|
||||||
import { fetchOrderList, cancelOrderById, fetchOrderDetailAdmin, updateOrder, submitOrderToReview, changeOrderStatus, settleOrder, fetchSupplierList, fetchDriverList, createLogisticsTask, generateSupplierText, cancelLogisticsTask, fetchProductOptions, cancelApproveOrder, updateOrderTracking, updateLogisticsTracking } from '../mockApi';
|
import { fetchOrderList, cancelOrderById, fetchOrderDetailAdmin, updateOrder, submitOrderToReview, changeOrderStatus, settleOrder, fetchSupplierList, fetchDriverList, createLogisticsTask, generateSupplierText, cancelLogisticsTask, fetchProductOptions, cancelApproveOrder, updateOrderTracking, updateLogisticsTracking, batchMatchLogisticsPreview, batchMatchLogisticsConfirm, localUploadFile } from '../mockApi';
|
||||||
import { apiBaseUrl } from '../config';
|
import { apiBaseUrl } from '../config';
|
||||||
import PaginationBar from '../components/PaginationBar.vue';
|
import PaginationBar from '../components/PaginationBar.vue';
|
||||||
import Drawer from '../components/Drawer.vue';
|
import Drawer from '../components/Drawer.vue';
|
||||||
@ -1002,6 +1119,132 @@ async function doUpdateSelfTracking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 批量匹配物流相关 ====================
|
||||||
|
const showBatchMatchModal = ref(false);
|
||||||
|
const batchMatchTab = ref('text');
|
||||||
|
const batchMatchText = ref('');
|
||||||
|
const batchMatchTableImageName = ref('');
|
||||||
|
const batchMatchTableImageUrl = ref('');
|
||||||
|
const batchMatchExpressImageCount = ref(0);
|
||||||
|
const batchMatchExpressImageUrls = ref([]);
|
||||||
|
const batchMatchRecognizing = ref(false);
|
||||||
|
const batchMatchConfirming = ref(false);
|
||||||
|
const batchMatchResults = ref([]);
|
||||||
|
|
||||||
|
function openBatchMatchModal() {
|
||||||
|
batchMatchTab.value = 'text';
|
||||||
|
batchMatchText.value = '';
|
||||||
|
batchMatchTableImageName.value = '';
|
||||||
|
batchMatchTableImageUrl.value = '';
|
||||||
|
batchMatchExpressImageCount.value = 0;
|
||||||
|
batchMatchExpressImageUrls.value = [];
|
||||||
|
batchMatchRecognizing.value = false;
|
||||||
|
batchMatchConfirming.value = false;
|
||||||
|
batchMatchResults.value = [];
|
||||||
|
showBatchMatchModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchMatchTableImage(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
batchMatchTableImageName.value = file.name;
|
||||||
|
try {
|
||||||
|
const data = await localUploadFile(file);
|
||||||
|
batchMatchTableImageUrl.value = data.url;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('图片上传失败: ' + (e.message || '未知错误'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchMatchExpressImages(event) {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
batchMatchExpressImageUrls.value = [];
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const data = await localUploadFile(file);
|
||||||
|
batchMatchExpressImageUrls.value.push(data.url);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(file.name + ' 上传失败: ' + (e.message || '未知错误'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batchMatchExpressImageCount.value = batchMatchExpressImageUrls.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchMatchRecognize() {
|
||||||
|
batchMatchRecognizing.value = true;
|
||||||
|
try {
|
||||||
|
let payload;
|
||||||
|
if (batchMatchTab.value === 'text') {
|
||||||
|
if (!batchMatchText.value.trim()) {
|
||||||
|
toast.error('请输入物流文本');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload = { mode: 'text', text: batchMatchText.value };
|
||||||
|
} else if (batchMatchTab.value === 'image_table') {
|
||||||
|
if (!batchMatchTableImageUrl.value) {
|
||||||
|
toast.error('请先上传物流表格图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload = { mode: 'image_table', image_urls: [batchMatchTableImageUrl.value] };
|
||||||
|
} else if (batchMatchTab.value === 'express_images') {
|
||||||
|
if (!batchMatchExpressImageUrls.value.length) {
|
||||||
|
toast.error('请先上传快递面单照片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload = { mode: 'express_images', image_urls: batchMatchExpressImageUrls.value };
|
||||||
|
}
|
||||||
|
const data = await batchMatchLogisticsPreview(payload);
|
||||||
|
batchMatchResults.value = (data.items || []).map(item => ({
|
||||||
|
...item,
|
||||||
|
order_id: null,
|
||||||
|
}));
|
||||||
|
if (!batchMatchResults.value.length) {
|
||||||
|
toast.error('未识别到物流信息');
|
||||||
|
} else {
|
||||||
|
toast.success(`识别到 ${batchMatchResults.value.length} 条物流信息`);
|
||||||
|
// 自动选择唯一匹配的订单
|
||||||
|
batchMatchResults.value.forEach(item => {
|
||||||
|
if (item.candidates && item.candidates.length === 1) {
|
||||||
|
item.order_id = item.candidates[0].order_id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('识别失败: ' + (e.message || '未知错误'));
|
||||||
|
} finally {
|
||||||
|
batchMatchRecognizing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchMatchConfirm() {
|
||||||
|
const toConfirm = batchMatchResults.value.filter(r => r.order_id);
|
||||||
|
if (!toConfirm.length) {
|
||||||
|
toast.error('请至少选择一条匹配记录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
batchMatchConfirming.value = true;
|
||||||
|
try {
|
||||||
|
const result = await batchMatchLogisticsConfirm(toConfirm.map(r => ({
|
||||||
|
tracking_number: r.tracking_number,
|
||||||
|
express_company: r.express_company,
|
||||||
|
recipient_name: r.recipient_name,
|
||||||
|
order_id: r.order_id,
|
||||||
|
})));
|
||||||
|
if (result.fail_count > 0) {
|
||||||
|
toast.info(`成功 ${result.success_count} 条,失败 ${result.fail_count} 条`);
|
||||||
|
} else {
|
||||||
|
toast.success(`已成功匹配 ${result.success_count} 条物流信息`);
|
||||||
|
}
|
||||||
|
showBatchMatchModal.value = false;
|
||||||
|
batchMatchResults.value = [];
|
||||||
|
await handleSearch();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('确认匹配失败: ' + (e.message || '未知错误'));
|
||||||
|
} finally {
|
||||||
|
batchMatchConfirming.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return localStorage.getItem('admin_token') || '';
|
return localStorage.getItem('admin_token') || '';
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user