diff --git a/backend/app/api/orders.py b/backend/app/api/orders.py index 1e724c2..5b8723a 100644 --- a/backend/app/api/orders.py +++ b/backend/app/api/orders.py @@ -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) diff --git a/backend/app/schemas/orders.py b/backend/app/schemas/orders.py index ee9f9c1..677de66 100644 --- a/backend/app/schemas/orders.py +++ b/backend/app/schemas/orders.py @@ -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) + """用户确认后的匹配列表,至少一条""" diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index 7e23ece..e02d6bf 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -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'(? 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() diff --git a/frontend/web-admin/src/mockApi.js b/frontend/web-admin/src/mockApi.js index b2879b9..1191774 100644 --- a/frontend/web-admin/src/mockApi.js +++ b/frontend/web-admin/src/mockApi.js @@ -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 }), + }); +} + // ========== 报表统计 ========== /** diff --git a/frontend/web-admin/src/views/OrdersPage.vue b/frontend/web-admin/src/views/OrdersPage.vue index a0f7885..5ca8d9a 100644 --- a/frontend/web-admin/src/views/OrdersPage.vue +++ b/frontend/web-admin/src/views/OrdersPage.vue @@ -23,8 +23,11 @@
-

订单列表

- 按条件筛选订单 +
+

订单列表

+ 按条件筛选订单 +
+
@@ -659,8 +662,122 @@
+ + +