新增订单管理页批量匹配物流功能,支持文本/表格截图/快递面单三种模式

- 文本粘贴:正则解析"快递公司+单号+收件人"多行文本
- 物流表格截图:OCR识别后用LLM提取快递单号和收件人
- 快递面单照片:复用快递100面单OCR接口识别单号
- 按customer_name精确匹配订单,同名时展开选择
- 已有单号标记提示,确认后覆盖
- 含唯一性检查、审计日志、缓存清理

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wsb1224 2026-06-24 18:16:57 +08:00
parent 1d12d061e0
commit a39bd4bc09
5 changed files with 587 additions and 5 deletions

View File

@ -16,6 +16,8 @@ from backend.app.db import get_db_session
from backend.app.schemas.common import success_payload
from backend.app.schemas.orders import (
ApproveOrderRequest,
BatchMatchConfirmRequest,
BatchMatchLogisticsRequest,
CancelOrderRequest,
ChangeOrderStatusRequest,
ConfirmSupplierTextRequest,
@ -394,3 +396,59 @@ def update_order_tracking(
if not order:
raise AppException(code=ErrorCode.NOT_FOUND, message="订单不存在", status_code=404)
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)

View File

@ -307,4 +307,39 @@ class UpdateOrderTrackingRequest(BaseModel):
"""快递单号"""
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)
"""用户确认后的匹配列表,至少一条"""

View File

@ -8,6 +8,7 @@
from datetime import datetime
import json
import re
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
@ -1809,5 +1810,224 @@ class OrderService:
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_countfail_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()

View File

@ -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 }),
});
}
// ========== 报表统计 ==========
/**

View File

@ -23,8 +23,11 @@
<!-- 订单列表与筛选 -->
<section class="panel">
<div class="panel-header">
<h3>订单列表</h3>
<span>按条件筛选订单</span>
<div>
<h3>订单列表</h3>
<span>按条件筛选订单</span>
</div>
<button class="primary-btn" @click="openBatchMatchModal">批量匹配物流</button>
</div>
<!-- 筛选工具栏 -->
<section class="toolbar toolbar-4">
@ -659,8 +662,122 @@
</div>
</section>
</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">&times;</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 马静&#10;壹米滴答 113040495943 收件人辜益华&#10;壹米滴答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>
</template>
</div>
<script setup>
/**
@ -670,7 +787,7 @@
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
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 PaginationBar from '../components/PaginationBar.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() {
return localStorage.getItem('admin_token') || '';
}