dingdanquanliucheng/docs/smart-order-entry-design.md

70 KiB
Raw Blame History

智能填单功能方案设计

一、功能概述

1.1 背景

当前业务员在创建订单时需要手动填写 4 个信息分区(客户信息、订单信息、物流费用、产品明细),字段多且重复录入频繁。业务员日常接收订单的方式主要是微信聊天、电话沟通、拍照转发,信息散落在各种非结构化场景中。

1.2 目标

提供两种快速录入入口,让业务员像顺丰快递员一样"粘贴一下就能出单"

  • 文本粘贴解析:把微信聊天记录、电话记录等文本直接粘贴,系统自动识别客户信息和产品明细
  • 图片智能识别:拍照或从相册选取订单图片(手写单、名片、聊天截图),系统 OCR 识别后自动填充

1.3 与现有系统的关系

本功能是现有下单流程(OrderFormPage.vuePOST /api/orders)的前置增强,不改变已有订单创建逻辑。解析结果最终仍通过现有的 createOrder 接口提交。

1.3 核心体验流程

业务员打开新建订单页面
  ↓
看到"智能填单"入口(文本粘贴 / 图片上传 两个 Tab
  ↓
输入内容 → 点击"智能解析"
  ↓
系统返回结构化预览(逐字段展示,标注置信度)
  ↓
业务员核对、修正(可选)
  ↓
点击"确认填入" → 自动填充到表单对应字段
  ↓
业务员补充缺失字段 → 提交订单

二、系统架构

2.1 现有基础设施盘点

能力 现有位置 当前状态
OSS 图片上传 file_service.py / storage_service.py 完整可用,前端有上传凭证流程
OCR 识别 ai_service.pyAliyunOCRProvider 可提取文本行列表和置信度,但当前仅做通用文本提取,无订单场景专项处理
阿里云 AI 配置 config.pyendpoint/key/region 已配置,可扩展
订单数据模型 schemas/orders.pyCreateOrderRequest 完整,新功能的输出需对齐此结构
产品库 product_repository.py 前端 fetchProductOptions() 可获取完整列表
客户库 customer_repository.py 前端 fetchCustomerOptions() 可获取完整列表

2.2 现有 OCR 能力与不足

当前 AliyunOCRProvider 返回的数据结构:

raw_result = {
    "provider": "aliyun_ocr",
    "model": "default",
    "image_url": "...",
    "payload": { ... },  # 阿里云原始响应
}
suggested_result = {
    "recognized_text": "整段文本",
    "line_list": ["第1行", "第2行", ...],  # 逐行文本
    "customer_name": "第一行文本截取",
}
confidence = 0.92  # OCR 置信度

现有不足line_listconfidence 这两个关键数据在现有流程中被浪费了。line_list 包含了文本的行级结构信息(顺序、分行),可以用来做版面分析;confidence 反映了图片清晰度和识别质量,应该参与到最终解析置信度的计算中。

2.3 新增组件

┌─────────────────────────────────────────────────────┐
│                   前端 OrderFormPage.vue              │
│  ┌──────────────┐  ┌──────────────┐                  │
│  │  文本粘贴 Tab │  │  图片上传 Tab │                  │
│  └──────┬───────┘  └──────┬───────┘                  │
│         │                  │                          │
│         ↓                  ↓                          │
│  ┌─────────────────────────────┐                     │
│  │   POST /api/ai/parse-order  │                     │
│  └─────────────┬───────────────┘                     │
│                │                                     │
│  ┌─────────────↓───────────────┐                     │
│  │     确认填充弹窗/预览层       │                     │
│  └─────────────┬───────────────┘                     │
│                │ 确认                                │
│  ┌─────────────↓───────────────┐                     │
│  │     表单自动填充              │                     │
│  └─────────────────────────────┘                     │
└─────────────────────────────────────────────────────┘
                          │
                          │ HTTP
                          ↓
┌─────────────────────────────────────────────────────┐
│                   后端 API 层                         │
│  ┌─────────────────────────────┐                     │
│  │  POST /api/ai/parse-order   │                     │
│  │  api/ai.py 新增端点)       │                     │
│  └─────────────┬───────────────┘                     │
│                │                                     │
│  ┌─────────────↓───────────────┐                     │
│  │     AIService.parse_order() │                     │
│  │     ai_service.py 新增方法)│                     │
│  └──┬──────────────┬───────────┘                     │
│     │              │                                 │
│     │ 图片模式      │ 文本模式                         │
│     ↓              ↓                                 │
│  ┌────────────┐  ┌────────┐                          │
│  │ OrderOCR   │  │        │                          │
│  │ Parser     │  │        │                          │
│  │ (版面分析)  │  │        │                          │
│  │ ↓          │  │        │                          │
│  │ AliyunOCR  │  │        │                          │
│  │ Provider   │  │        │                          │
│  └─────┬──────┘  │        │                          │
│        │         │        │                          │
│        ↓         ↓        │                          │
│  ┌──────────────────────┐ │                          │
│  │   正则提取层           │ │                          │
│  │   RuleExtractor       │ │                          │
│  │   + OCR 预填充合并     │ │                          │
│  └──────────┬───────────┘ │                          │
│             ↓             │                          │
│  ┌──────────────────────┐ │                          │
│  │  LLM 结构化解析       │ │                          │
│  │  LLMOrderParser       │ │                          │
│  │  (文本 prompt /       │ │                          │
│  │   图片 prompt)        │ │                          │
│  └──────────┬───────────┘ │                          │
│             ↓             │                          │
│  ┌──────────────────────┐ │                          │
│  │  合并 + 产品匹配      │ │                          │
│  │  + 综合置信度计算      │ │                          │
│  │  (含 OCR 置信度)      │ │                          │
│  └──────────────────────┘ │                          │
│                           │                          │
│  ┌─────────────────────────┐                         │
│  │  统一结构化输出           │                         │
│  │  对齐 CreateOrderRequest │                         │
│  └─────────────────────────┘                         │
└─────────────────────────────────────────────────────┘

三、接口设计

3.1 智能解析接口

POST /api/ai/parse-order

权限salesman / admin 角色,ai:recognize 权限

请求体

{
  "input_type": "text",
  "text": "张三 13812345678 杭州市西湖区文一路100号\n要A产品 规格25kg 200吨 单价85\n备注周五前要送到",
  "image_url": null
}
{
  "input_type": "image",
  "text": null,
  "image_url": "https://oss-cn-hangzhou.aliyuncs.com/bucket/uploads/xxx.jpg"
}

响应体

{
  "code": 0,
  "data": {
    "parsed_order": {
      "customer_name": "张三",
      "customer_mobile": "13812345678",
      "customer_address": "杭州市西湖区文一路100号",
      "order_source": null,
      "delivery_type": null,
      "factory_id": null,
      "remark": "周五前要送到",
      "items": [
        {
          "product_name": "A产品",
          "specification": "25kg",
          "unit": "吨",
          "quantity": 200,
          "sale_price": 85,
          "cost_price": null
        }
      ],
      "freight_total": null,
      "tax_total": null,
      "other_fee_total": null,
      "commission_amount": null
    },
    "raw_text": "张三 13812345678 杭州市西湖区文一路100号\n要A产品...",
    "confidence": 0.88,
    "ocr_confidence": 0.92,
    "parse_source": "hybrid",
    "product_matches": [
      {
        "input_name": "A产品",
        "candidates": [
          { "product_id": 101, "product_name": "精品A型", "match_score": 0.82 },
          { "product_id": 205, "product_name": "A系列标准品", "match_score": 0.75 }
        ]
      }
    ],
    "warnings": ["未识别到订单来源", "未识别到配送方式"]
  }
}

ocr_confidence 仅在图片输入模式下返回,文本模式下为 nullconfidence 是综合置信度OCR 置信度 × 字段完整度折扣),ocr_confidence 是纯 OCR 识别置信度,两者独立返回便于前端分别展示。

3.2 Schema 定义

schemas/ai.py 新增

class ParseOrderRequest(BaseModel):
    input_type: str = Field(pattern="^(text|image)$")
    text: str | None = None
    image_url: str | None = None

    @model_validator(mode="after")
    def validate_input(self):
        if self.input_type == "text" and not (self.text or "").strip():
            raise ValueError("文本模式下 text 不能为空")
        if self.input_type == "image" and not (self.image_url or "").strip():
            raise ValueError("图片模式下 image_url 不能为空")
        return self


class ParsedOrderItem(BaseModel):
    product_name: str
    specification: str | None = None
    unit: str | None = None
    quantity: float | None = None
    sale_price: float | None = None
    cost_price: float | None = None
    remark: str | None = None


class ProductMatch(BaseModel):
    input_name: str
    candidates: list[dict]


class ParseOrderResponse(BaseModel):
    parsed_order: dict
    raw_text: str
    confidence: float
    ocr_confidence: float | None = None  # 仅图片模式返回
    parse_source: str  # "rule" | "llm" | "hybrid"
    product_matches: list[ProductMatch]
    warnings: list[str]

四、核心解析逻辑

4.0 文本预处理TextPreprocessor

业务员粘贴的原始文本往往包含大量噪声——微信聊天的时间戳、用户名、表情符号、[图片] 标记等。这些噪声会干扰 LLM 解析,必须在进入解析管线之前清洗。

import re


class TextPreprocessor:
    """清洗粘贴文本中的噪声,保留有效订单信息。"""

    # 微信聊天时间戳2024-03-15 14:30:22 或 [14:30]
    TIMESTAMP_RE = re.compile(r'\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{2}(:\d{2})?')
    BRACKET_TIME_RE = re.compile(r'\[\d{1,2}:\d{2}(:\d{2})?\]')

    # 微信用户名前缀:张三: 消息内容
    USERNAME_PREFIX_RE = re.compile(r'^[^:\s]{1,10}[:]\s*', re.MULTILINE)

    # 常见无意义标记
    NOISE_MARKERS = ['[图片]', '[表情]', '[语音]', '[视频]', '[文件]', '[链接]',
                     '[红包]', '[转账]', '[位置]', '[名片]', '—— ——']

    def preprocess(self, text: str) -> str:
        """清洗原始文本,返回适合解析的干净文本。"""
        if not text or not text.strip():
            return ""

        result = text

        # 1. 去除时间戳
        result = self.TIMESTAMP_RE.sub('', result)
        result = self.BRACKET_TIME_RE.sub('', result)

        # 2. 去除无意义标记
        for marker in self.NOISE_MARKERS:
            result = result.replace(marker, '')

        # 3. 去除微信用户名前缀(只在行首出现的短名称 + 冒号)
        result = self.USERNAME_PREFIX_RE.sub('', result)

        # 4. 合并连续空行为单行
        result = re.sub(r'\n{3,}', '\n\n', result)

        # 5. 去除首尾空白
        result = result.strip()

        return result

此预处理器在所有解析路径之前调用,文本模式和图片模式都经过清洗后再进入后续流程。

4.1 解析流程

图片输入和文本输入走不同的前处理路径OCR 不再是简单的"图片转文字",而是作为独立的预处理层参与整个解析过程:

输入
  │
  ├─ input_type = "image"
  │    ↓
  │  ┌─────────────────────────────────┐
  │  │  OCR 预处理层OrderOCRParser  │
  │  │                                 │
  │  │  1. 调用 AliyunOCRProvider       │
  │  │     获取 line_list + confidence  │
  │  │                                 │
  │  │  2. 版面分析:按行扫描,识别     │
  │  │     - 标题行 / 表头行            │
  │  │     - 地址区域                   │
  │  │     - 表格数据区域(产品明细)    │
  │  │                                 │
  │  │  3. 区域级正则提取:             │
  │  │     - 标题区域 → 客户姓名        │
  │  │     - 地址区域 → 客户地址        │
  │  │     - 表格区域 → 产品行解析      │
  │  │                                 │
  │  │  4. 输出:                      │
  │  │     - raw_text合并后文本     │
  │  │     - ocr_confidenceOCR置信度│
  │  │     - layout_zones版面分区   │
  │  └───────────┬─────────────────────┘
  │              ↓
  │  OCR 结构化中间结果
  │    { raw_text, ocr_confidence, layout_zones,
  │      pre_filled: {customer_name, customer_mobile, ...} }
  │
  ├─ input_type = "text"
  │    ↓
  │  raw_text = text
  │  ocr_confidence = null不需要 OCR
  │  layout_zones = null
  │  pre_filled = {}(无预填充)
  │
  ↓
正则提取层(毫秒级,确定性字段)
  │  - 手机号:/1[3-9]\d{9}/
  │  - 数量+单位:/(\d+\.?\d*)\s*(吨|件|箱|包|个|米|kg)/
  │  - 价格:/单价\s*[:]?\s*(\d+\.?\d*)/
  │  - 地址关键词提取
  │  - 合并 OCR 预填充的字段(如有)
  │
  ↓
规则提取结果(部分字段已有值)
  │
  ↓
LLM 结构化解析(处理模糊/缺失部分)
  │  调用 dashscope qwen-plus
  │  prompt 中附带:
  │    - raw_text
  │    - 正则已提取的字段
  │    - OCR 版面分区提示(如 "第3-5行为产品表格区域"
  │    - 产品库名称列表
  │
  ↓
合并结果:正则确定的字段 > LLM 推断的字段
  │
  ↓
产品库模糊匹配(前端做)
  │
  ↓
置信度计算:综合 OCR 置信度 + 字段完整度 + 警告数
  │
  ↓
返回结构化订单数据

4.2 OCR 预处理层OrderOCRParser

这是本次设计的核心新增。现有的 AliyunOCRProvider 是通用 OCR不认识订单的结构。OrderOCRParser 在其上层做订单场景的版面分析,把散乱的文本行转化为有结构的中间数据。

import re


class OrderOCRParser:
    """基于 OCR 行列表做订单版面分析,提取结构化中间数据。

    核心思路OCR 返回的 line_list 是有序的文本行列表。
    不同行在图片中的空间位置隐含了语义分区——
    第一行通常是标题/客户名,中间有地址行,下方可能是表格。
    即使没有坐标信息,行的顺序和内容关键词也足以做分区判断。
    """

    # --- 关键词规则:用于识别每行的语义角色 ---

    PHONE_RE = re.compile(r'1[3-9]\d{9}')
    # 地址特征词
    ADDRESS_KEYWORDS = {
        '省', '市', '区', '县', '镇', '路', '街', '号',
        '楼', '室', '栋', '单元', '村', '大厦', '广场',
        '弄', '巷', '苑', '园', '城',
    }
    # 数量+单位
    QTY_RE = re.compile(
        r'(\d+\.?\d*)\s*(吨|件|箱|包|个|米|kg|KG|公斤|斤|卷|组|套|台|条|根|片|块)'
    )
    # 价格
    PRICE_RE = re.compile(r'(?:单价|价格|报价|¥|¥)\s*[:]?\s*(\d+\.?\d*)')
    # 表头关键词(标识产品表格的开始)
    TABLE_HEADER_KEYWORDS = {'产品', '品名', '名称', '规格', '数量', '单价', '金额', '合计'}

    def parse(self, line_list: list[str], ocr_confidence: float) -> dict:
        """从 OCR 行列表中提取结构化中间数据。

        Returns:
            {
                "raw_text": "合并后的完整文本",
                "ocr_confidence": 0.92,
                "layout_zones": {
                    "header_lines": [0, 1],      # 标题/客户区域的行索引
                    "address_lines": [2],          # 地址区域的行索引
                    "table_lines": [3, 4, 5],     # 产品表格区域的行索引
                    "other_lines": [6],            # 其他行
                },
                "pre_filled": {
                    "customer_name": "张三",
                    "customer_mobile": "13812345678",
                    "customer_address": "杭州市西湖区文一路100号",
                },
                "table_rows": [
                    {"raw": "A产品 25kg 200吨 85元", "fields": {...}},
                ],
            }
        """
        raw_text = '\n'.join(line_list)
        zones = self._classify_lines(line_list)
        pre_filled = self._extract_from_zones(line_list, zones)
        table_rows = self._parse_table_lines(line_list, zones.get('table_lines', []))

        return {
            "raw_text": raw_text,
            "ocr_confidence": ocr_confidence,
            "layout_zones": zones,
            "pre_filled": pre_filled,
            "table_rows": table_rows,
        }

    def _classify_lines(self, lines: list[str]) -> dict:
        """将每行文本分类到不同的版面区域。"""
        zones = {
            "header_lines": [],
            "address_lines": [],
            "table_lines": [],
            "other_lines": [],
        }
        table_started = False
        for i, line in enumerate(lines):
            stripped = line.strip()
            if not stripped:
                continue
            # 如果当前行或之前的行出现了表头关键词,标记表格区域开始
            if any(kw in stripped for kw in self.TABLE_HEADER_KEYWORDS):
                table_started = True
                zones["table_lines"].append(i)
                continue
            if table_started:
                # 表格区域内的行:如果包含数量或价格特征,归入表格
                if self.QTY_RE.search(stripped) or self.PRICE_RE.search(stripped):
                    zones["table_lines"].append(i)
                    continue
                # 否则可能表格结束
                table_started = False
            # 地址行判断
            addr_count = sum(1 for kw in self.ADDRESS_KEYWORDS if kw in stripped)
            if addr_count >= 2:
                zones["address_lines"].append(i)
                continue
            # 默认归入标题/头部区域(前几行)或其它
            if i < 3:
                zones["header_lines"].append(i)
            else:
                zones["other_lines"].append(i)
        return zones

    def _extract_from_zones(self, lines: list[str], zones: dict) -> dict:
        """从各区域中提取具体字段。"""
        result = {}
        # 标题区域:提取客户姓名(通常是第一行非空文本)
        for i in zones.get("header_lines", []):
            text = lines[i].strip()
            if not text:
                continue
            # 如果标题行中包含手机号,先提取
            phone_match = self.PHONE_RE.search(text)
            if phone_match:
                result["customer_mobile"] = phone_match.group(0)
            # 去掉手机号后剩余部分可能是姓名
            name_part = self.PHONE_RE.sub('', text).strip()
            if name_part and len(name_part) <= 20 and not any(
                kw in name_part for kw in self.ADDRESS_KEYWORDS
            ):
                result.setdefault("customer_name", name_part)
        # 地址区域
        addr_parts = []
        for i in zones.get("address_lines", []):
            addr_parts.append(lines[i].strip())
        if addr_parts:
            result["customer_address"] = ''.join(addr_parts)
        return result

    def _parse_table_lines(self, lines: list[str],
                           table_indices: list[int]) -> list[dict]:
        """尝试解析表格区域的每一行为产品明细。"""
        rows = []
        for i in table_indices:
            line = lines[i].strip()
            if not line:
                continue
            # 跳过纯表头行
            if any(kw in line for kw in self.TABLE_HEADER_KEYWORDS) and not self.QTY_RE.search(line):
                continue
            row = {"raw": line, "fields": {}}
            qty_match = self.QTY_RE.search(line)
            if qty_match:
                row["fields"]["quantity"] = float(qty_match.group(1))
                row["fields"]["unit"] = qty_match.group(2)
            price_match = self.PRICE_RE.search(line)
            if price_match:
                row["fields"]["sale_price"] = float(price_match.group(1))
            # 产品名称:去掉数字和价格后剩余的文本片段
            name_part = self.QTY_RE.sub('', line)
            name_part = self.PRICE_RE.sub('', name_part)
            name_part = re.sub(r'\d+\.?\d*', '', name_part).strip()
            if name_part:
                row["fields"]["product_name"] = name_part
            if row["fields"]:
                rows.append(row)
        return rows

4.2 正则提取层

import re

class RuleExtractor:
    """基于规则的字段提取器,处理确定性高的字段。"""

    PHONE_RE = re.compile(r'1[3-9]\d{9}')
    # 数量+单位:支持 200吨、200 吨、200.5kg 等
    QTY_UNIT_RE = re.compile(r'(\d+\.?\d*)\s*(吨|件|箱|包|个|米|kg|KG|kg|吨|公斤|斤|卷|组|套|台|条|根|片|块)')
    # 单价
    PRICE_RE = re.compile(r'(?:单价|价格|报价)\s*[:]?\s*(\d+\.?\d*)')
    # 地址关键词
    ADDRESS_KEYWORDS = ['省', '市', '区', '县', '镇', '路', '街', '号', '楼', '室', '栋', '单元', '村', '大厦', '广场']

    def extract(self, text: str) -> dict:
        result = {}

        # 手机号
        phones = self.PHONE_RE.findall(text)
        if phones:
            result['customer_mobile'] = phones[0]

        # 数量(取第一个匹配作为整体数量,产品级数量在 LLM 层处理)
        qty_match = self.QTY_UNIT_RE.search(text)
        if qty_match:
            result['_raw_quantity'] = float(qty_match.group(1))
            result['_raw_unit'] = qty_match.group(2)

        # 单价
        price_match = self.PRICE_RE.search(text)
        if price_match:
            result['_raw_price'] = float(price_match.group(1))

        # 地址(基于关键词的启发式提取)
        result['customer_address'] = self._extract_address(text)

        return result

    def _extract_address(self, text: str) -> str | None:
        """从文本中提取包含地址关键词的连续片段。

        策略:按行扫描,找到连续包含地址关键词的行片段,
        拼接后返回。跳过明显不是地址的行(纯数字行、纯标点行等)。
        """
        lines = text.split('\n')
        best片段: list[str] = []
        current片段: list[str] = []

        for line in lines:
            stripped = line.strip()
            if not stripped:
                # 空行可能分隔地址段落,暂不打断
                continue
            addr_hits = sum(1 for kw in self.ADDRESS_KEYWORDS if kw in stripped)
            if addr_hits >= 2:
                current片段.append(stripped)
            else:
                if len(current片段) > len(best片段):
                    best片段 = list(current片段)
                current片段 = []

        if len(current片段) > len(best片段):
            best片段 = current片段

        return ''.join(best片段) if best片段 else None

4.3 LLM 结构化解析

LLM 的 prompt 需要根据输入来源做区分——图片模式下OCR 已经做了版面分析和部分字段提取prompt 应该把这些中间结果告诉 LLM让它专注于处理 OCR 搞不定的部分,而不是从零开始解析。

import json
from urllib import request as urllib_request


class LLMOrderParser:
    """调用 dashscope qwen-plus 进行订单文本结构化解析。"""

    # 文本输入模式的 prompt从零解析
    TEXT_SYSTEM_PROMPT = """你是订单信息解析助手。从用户提供的文本中提取订单信息。

严格按以下 JSON 格式输出,不要输出其他内容:
{
  "customer_name": "客户姓名",
  "customer_mobile": "手机号",
  "customer_address": "地址",
  "order_source": "订单来源(如能识别)",
  "delivery_type": "配送方式(如能识别)",
  "remark": "备注",
  "items": [
    {
      "product_name": "产品名称",
      "specification": "规格",
      "unit": "单位",
      "quantity": 数量数字,
      "sale_price": 单价数字
    }
  ]
}

规则:
- 手机号必须是 11 位数字,以 1 开头
- 数量和价格必须是数字(不是字符串)
- 无法识别的字段填 null不要编造
- 如果文本中有多个产品,每个产品一个 items 条目

可选的产品库名称(供参考匹配):
{product_names}"""

    # 图片/OCR 模式的 prompt基于 OCR 已提取的信息补充解析
    IMAGE_SYSTEM_PROMPT = """你是订单信息解析助手。OCR 系统已经从图片中提取了文本并做了初步分析。
你需要基于 OCR 的结果,补充和完善订单信息。

OCR 已提取的信息:
- OCR 置信度:{ocr_confidence}
- 已识别的客户姓名:{customer_name}
- 已识别的客户手机:{customer_mobile}
- 已识别的客户地址:{customer_address}
- 产品表格区域识别到的原始行:
{table_rows}

你需要完成:
1. 验证 OCR 提取的字段是否合理(如手机号格式、地址完整性)
2. 从原始文本中补充 OCR 未提取到的字段
3. 解析产品明细(如果 OCR 表格区域数据可用,优先使用)
4. 识别订单来源、配送方式等附加信息

严格按以下 JSON 格式输出:
{
  "customer_name": "客户姓名",
  "customer_mobile": "手机号",
  "customer_address": "地址",
  "order_source": "订单来源",
  "delivery_type": "配送方式",
  "remark": "备注",
  "items": [
    {
      "product_name": "产品名称",
      "specification": "规格",
      "unit": "单位",
      "quantity": 数量数字,
      "sale_price": 单价数字
    }
  ]
}

规则:
- 无法识别的字段填 null不要编造
- 如果 OCR 已提取的字段看起来正确,直接沿用

可选的产品库名称(供参考匹配):
{product_names}"""

    def parse(self, text: str, product_names: list[str],
              api_key: str, api_url: str,
              ocr_context: dict | None = None) -> dict:
        """解析订单文本。

        Args:
            ocr_context: 图片模式下传入,包含 OCR 预处理层的中间结果。
                         文本模式下为 None。
        """
        if ocr_context:
            system_prompt = self.IMAGE_SYSTEM_PROMPT.format(
                ocr_confidence=ocr_context.get("ocr_confidence", "N/A"),
                customer_name=ocr_context.get("pre_filled", {}).get("customer_name", "未识别"),
                customer_mobile=ocr_context.get("pre_filled", {}).get("customer_mobile", "未识别"),
                customer_address=ocr_context.get("pre_filled", {}).get("customer_address", "未识别"),
                table_rows='\n'.join(
                    f"  - {row['raw']}" for row in ocr_context.get("table_rows", [])
                ) or "  无",
                product_names='\n'.join(f'- {name}' for name in product_names[:50]),
            )
        else:
            system_prompt = self.TEXT_SYSTEM_PROMPT.format(
                product_names='\n'.join(f'- {name}' for name in product_names[:50]),
            )

        payload = json.dumps({
            "model": "qwen-plus",
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": f"请解析以下订单内容:\n\n{text}"},
            ],
            "temperature": 0.1,
            "max_tokens": 1024,
        }).encode("utf-8")

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        req = urllib_request.Request(
            url=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(content)

    def _extract_json(self, text: str) -> dict:
        """从 LLM 响应中提取 JSON兼容 markdown 代码块包裹。"""
        text = text.strip()
        if text.startswith("```"):
            text = text.split("\n", 1)[1]
            text = text.rsplit("```", 1)[0]
        return json.loads(text.strip())

    def safe_parse(self, text: str, product_groups: list[dict],
                   api_key: str, api_url: str,
                   ocr_context: dict | None = None) -> dict | None:
        """安全调用 LLM失败时返回 None由上层退化为纯规则模式。"""
        try:
            return self.parse(text, product_groups, api_key, api_url, ocr_context)
        except Exception:
            # LLM 调用失败网络超时、API 报错、JSON 解析失败等)
            # 不向上抛异常,返回 None 让调用方用规则提取结果兜底
            return None

4.4 AIService 新增方法

# ai_service.py 新增

def parse_order(self, payload: dict, session: Session | None = None) -> dict:
    input_type = payload["input_type"]

    # 1. 获取原始文本 + OCR 中间结果
    ocr_context = None
    if input_type == "image":
        ocr_context = self._ocr_and_parse_image(payload["image_url"])
        raw_text = ocr_context["raw_text"]
    else:
        raw_text = payload["text"].strip()

    # 2. 文本预处理:清洗噪声(微信时间戳、表情标记等)
    preprocessor = TextPreprocessor()
    raw_text = preprocessor.preprocess(raw_text)

    if not raw_text:
        raise AppException(
            code=ErrorCode.PARAM_ERROR,
            message="无法提取到文本内容",
            status_code=400,
        )

    # 3. 正则提取
    rule_extractor = RuleExtractor()
    rule_result = rule_extractor.extract(raw_text)
    if ocr_context and ocr_context.get("pre_filled"):
        for key, value in ocr_context["pre_filled"].items():
            if value and not rule_result.get(key):
                rule_result[key] = value

    # 4. LLM 结构化解析(传入 OCR 上下文,失败时退化为纯规则模式)
    product_groups = self._get_product_groups(session)
    llm_parser = LLMOrderParser()
    llm_result = llm_parser.safe_parse(
        raw_text,
        product_groups,
        settings.aliyun_ai_access_key_id,
        settings.llm_parse_api_url,
        ocr_context=ocr_context,
    )

    # 5. 合并LLM 成功时走完整路径,失败时退化为规则模式
    if llm_result:
        merged = self._merge_results(rule_result, llm_result)
        parse_source = "hybrid"
    else:
        merged = self._build_fallback_result(rule_result)
        parse_source = "rule"

    # 6. 客户库预匹配
    if session is not None and merged.get("customer_mobile"):
        try:
            existing_customer = self.customer_repository.find_by_mobile(
                session, merged["customer_mobile"]
            )
            if existing_customer:
                merged["customer_id"] = existing_customer.id
                if existing_customer.address and not merged.get("customer_address"):
                    merged["customer_address"] = existing_customer.address
        except Exception:
            pass

    # 7. 产品库模糊匹配
    product_matches = self._match_products(
        merged.get("items", []), product_groups, session
    )

    # 8. 校验 + 置信度
    warnings = self._validate_parsed_order(merged)
    if parse_source == "rule":
        warnings.append("LLM 服务不可用,仅使用规则提取,部分字段可能不完整")
    ocr_conf = ocr_context["ocr_confidence"] if ocr_context else None
    confidence = self._calc_confidence(merged, warnings, ocr_confidence=ocr_conf)

    return {
        "parsed_order": merged,
        "raw_text": raw_text,
        "confidence": confidence,
        "ocr_confidence": ocr_conf,
        "parse_source": parse_source,
        "is_mock": session is None,
        "product_matches": product_matches,
        "factory_options": self._get_factory_options(session),
        "warnings": warnings,
    }

def _ocr_and_parse_image(self, image_url: str) -> dict:
    """调用 OCR 并用 OrderOCRParser 做版面分析。

    Returns:
        OrderOCRParser.parse() 的完整输出。
    """
    provider = self._build_provider()
    raw_result, suggested_result, ocr_confidence = provider.recognize(
        image_url, "order_parse", 0
    )
    # 从 suggested_result 中获取行列表
    line_list = suggested_result.get("line_list", [])
    if not line_list:
        # 如果 OCR 没返回行列表,按换行符拆分
        text = suggested_result.get("recognized_text", "")
        line_list = [line for line in text.split("\n") if line.strip()]

    ocr_parser = OrderOCRParser()
    return ocr_parser.parse(line_list, ocr_confidence)

def _merge_results(self, rule_result: dict, llm_result: dict) -> dict:
    """正则确定的字段覆盖 LLM 推断的字段。"""
    merged = dict(llm_result)
    # 手机号:正则更可靠
    if rule_result.get("customer_mobile"):
        merged["customer_mobile"] = rule_result["customer_mobile"]
    # 地址:如果正则提取到了更长的地址,优先用正则的
    rule_addr = rule_result.get("customer_address", "")
    llm_addr = merged.get("customer_address", "")
    if rule_addr and len(rule_addr) > len(llm_addr or ""):
        merged["customer_address"] = rule_addr
    return merged

def _get_product_names(self, session: Session | None) -> list[str]:
    if session is not None:
        try:
            products = self.product_repository.list_products(session)
            return [p.product_name for p in products]
        except Exception:
            pass
    return ["演示产品A", "演示产品B"]

def _match_products(self, items: list[dict],
                    product_names: list[str],
                    session: Session | None) -> list[dict]:
    """对解析出的每个产品名称做模糊匹配。"""
    from difflib import SequenceMatcher
    matches = []
    for item in items:
        input_name = item.get("product_name", "")
        if not input_name:
            continue
        scored = [
            (name, SequenceMatcher(None, input_name, name).ratio())
            for name in product_names
        ]
        scored.sort(key=lambda x: x[1], reverse=True)
        matches.append({
            "input_name": input_name,
            "candidates": [
                {"product_name": name, "match_score": round(score, 2)}
                for name, score in scored[:3] if score > 0.3
            ],
        })
    return matches

def _validate_parsed_order(self, order: dict) -> list[str]:
    warnings = []
    if not order.get("customer_name"):
        warnings.append("未识别到客户姓名")
    if not order.get("customer_mobile"):
        warnings.append("未识别到客户手机号")
    if not order.get("items"):
        warnings.append("未识别到产品明细")
    for item in order.get("items", []):
        if not item.get("product_name"):
            warnings.append("存在产品名称为空的明细行")
        if not item.get("quantity"):
            warnings.append(f"产品 {item.get('product_name', '?')} 未识别到数量")
    return warnings

def _calc_confidence(self, order: dict, warnings: list[str],
                     ocr_confidence: float | None = None) -> float:
    """计算综合置信度。

    图片输入时综合 OCR 置信度;纯文本输入时 OCR 置信度为 null
    只基于字段完整度计算。
    """
    base = 1.0
    # OCR 置信度作为基础(图片模式)
    if ocr_confidence is not None:
        base = ocr_confidence
    # 字段完整度扣分
    base -= len(warnings) * 0.1
    if order.get("items"):
        for item in order["items"]:
            if not item.get("sale_price"):
                base -= 0.05
    return round(max(base, 0.1), 2)

def _build_fallback_result(self, rule_result: dict) -> dict:
    """LLM 不可用时,用规则提取结果构建最小可用的解析结果。"""
    items = []
    if rule_result.get("_raw_quantity"):
        items.append({
            "product_name": "",
            "specification": "",
            "unit": rule_result.get("_raw_unit", ""),
            "quantity": rule_result["_raw_quantity"],
            "sale_price": rule_result.get("_raw_price", 0),
            "cost_price": None,
        })
    return {
        "customer_name": rule_result.get("customer_name"),
        "customer_mobile": rule_result.get("customer_mobile"),
        "customer_address": rule_result.get("customer_address"),
        "order_source": None,
        "delivery_type": None,
        "factory_id": None,
        "remark": None,
        "items": items,
    }

def _get_product_groups(self, session: Session | None) -> list[dict]:
    """获取按名称分组的产品列表(含规格),复用 ProductService 的分组逻辑。"""
    if session is not None:
        try:
            from backend.app.services.product_service import product_service
            result = product_service.list_products({}, session)
            return result.get("list", [])
        except Exception:
            pass
    return [
        {
            "product_name": "演示产品A",
            "product_id": 2001,
            "specifications": [
                {"product_id": 2001, "specification": "10kg", "unit": "吨", "sale_price": 100, "cost_price": 60}
            ],
        },
        {
            "product_name": "演示产品B",
            "product_id": 2002,
            "specifications": [
                {"product_id": 2002, "specification": "20kg", "unit": "吨", "sale_price": 180, "cost_price": 120}
            ],
        },
    ]

def _get_factory_options(self, session: Session | None) -> list[dict]:
    """获取可用工厂列表,供前端在解析结果中选择。"""
    if session is not None:
        try:
            from backend.app.services.supplier_service import supplier_service
            result = supplier_service.list_suppliers(
                session, {"supplier_type": "factory"}
            )
            return [
                {"value": item["supplier_id"], "label": item["supplier_name"]}
                for item in result.get("list", [])
            ]
        except Exception:
            pass
    return [{"value": 1001, "label": "演示工厂A"}]

---

## 五、前端设计

### 5.1 交互设计

 `OrderFormPage.vue` 表单顶部插入"智能填单"卡片视觉上是一个独立面板解析成功后收起并展示已解析字段概要

┌─────────────────────────────────────────────────┐ │ 🤖 智能填单 │ │ │ │ [ 粘贴文本 ] [ 拍照/上传图片 ] │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ │ │ 请粘贴订单相关文本... │ │ │ │ (微信聊天记录、电话记录等) │ │ │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ [ 🪄 智能解析 ] │ │ │ └─────────────────────────────────────────────────┘


解析完成后变为确认预览模式:

┌─────────────────────────────────────────────────┐ │ 🤖 智能填单 - 解析结果 置信度 88% │ │ │ │ 客户姓名: 张三 [编辑] │ │ 客户手机: 13812345678 [编辑] │ │ 客户地址: 杭州市西湖区文一路100号 [编辑] │ │ ⚠️ 订单来源: 未识别 [填写] │ │ │ │ 产品明细: │ │ ┌─────────────────────────────────────────┐ │ │ │ A产品 / 25kg / 200吨 / 单价85 │ │ │ │ 匹配建议: → 精品A型 (82%) │ │ │ └─────────────────────────────────────────┘ │ │ │ │ ⚠️ 提示: 未识别到配送方式、未识别到工厂 │ │ │ │ [ 重新解析 ] [ 确认填入表单 ] │ └─────────────────────────────────────────────────┘


### 5.2 前端状态管理与数据流

"确认填入"是整个功能中最关键的环节——解析结果要正确映射到现有表单的状态结构中。以下是详细的数据流设计。

**新增响应式状态**(在 `<script setup>` 中):

```javascript
// 智能填单状态
const parseMode = ref("text");          // "text" | "image"
const parseInput = ref("");             // 文本模式的粘贴内容
const parseImageFile = ref(null);       // 图片模式的文件对象
const parseImagePreview = ref("");      // 图片缩略预览 URL
const parsedResult = ref(null);         // 后端返回的完整解析结果
const parseLoading = ref(false);
const parseEditable = ref({             // 确认阶段的可编辑副本
  customer_name: "",
  customer_mobile: "",
  customer_address: "",
  order_source: "",
  delivery_type: "",
  remark: "",
  items: [],                           // 每项带 _selectedCandidate 索引
});

确认填入的核心方法

function handleConfirmParse() {
  const parsed = parseEditable.value;

  // 1. 客户信息 → 表单 form
  form.customer_name = parsed.customer_name || "";
  form.customer_mobile = parsed.customer_mobile || "";
  form.customer_address = parsed.customer_address || "";
  form.order_source = parsed.order_source || "";
  form.delivery_type = parsed.delivery_type || "";
  form.remark = parsed.remark || "";

  // 2. 如果匹配到了现有客户,设置 selectedCustomerId
  if (parsedResult.value?.parsed_order?.customer_id) {
    const cid = parsedResult.value.parsed_order.customer_id;
    selectedCustomerId.value = String(cid);
    handleCustomerChange();  // 复用现有客户选择逻辑,自动补充剩余字段
  } else {
    selectedCustomerId.value = "";
  }

  // 3. 产品明细 → items 数组
  //    逐条处理:如果用户选了产品匹配候选,用候选的 product_id 和标准价格
  const newItems = [];
  for (const parsedItem of parsed.items) {
    const row = buildDefaultItem();

    // 如果有选中的产品匹配候选,直接关联
    if (parsedItem._selectedCandidate) {
      const candidate = parsedItem._selectedCandidate;
      row.selectedProductId = String(candidate.product_id);
      row.product_id = candidate.product_id;
      row.product_name = candidate.product_name;
      row.specification = candidate.specification;
      row.unit = candidate.unit;
      row.sale_price = candidate.sale_price;
      row.cost_price = candidate.cost_price;
    } else {
      // 没有匹配候选,保留解析出的原始值
      row.product_name = parsedItem.product_name || "";
      row.specification = parsedItem.specification || "";
      row.unit = parsedItem.unit || "";
      row.quantity = parsedItem.quantity || 1;
      row.sale_price = parsedItem.sale_price || 0;
      row.cost_price = parsedItem.cost_price || 0;
    }

    // 未匹配的产品,保留解析出的数量和价格
    if (!row.quantity || row.quantity <= 0) row.quantity = parsedItem.quantity || 1;
    if (row.sale_price <= 0 && parsedItem.sale_price > 0) row.sale_price = parsedItem.sale_price;

    newItems.push(row);
  }

  // 4. 确保至少有一行产品明细
  items.value = newItems.length > 0 ? newItems : [buildDefaultItem()];

  // 5. 收起智能填单面板
  parsedResult.value = null;
  parseEditable.value = { customer_name: "", customer_mobile: "", customer_address: "", order_source: "", delivery_type: "", remark: "", items: [] };
}

产品匹配候选的选择交互

在解析结果的每个产品行中,展示 product_matches 返回的候选列表。业务员点击某个候选时,将候选对象挂到 parseEditable.items[index]._selectedCandidateUI 上高亮选中项并显示标准价格。这样确认填入时可以直接用候选的价格覆盖解析价格。

5.3 产品匹配交互

对于每个解析出的产品,展示模糊匹配的候选列表。业务员可以:

  • 点击候选产品:自动关联 product_id、规格、标准单价和成本价UI 高亮显示
  • 手动编辑产品名称(当作新产品处理,_selectedCandidate 为 null
  • 如果产品库完全无匹配,保留解析出的名称和价格

5.4 图片上传流程

用户点击"拍照/上传"
  ↓
调用 <input type="file" accept="image/*" capture="environment">
  ↓
选择图片后,先在前端展示缩略预览
  ↓
调用现有 /api/files/upload-token 获取上传凭证
  ↓
上传到阿里云 OSS拿到 image_url
  ↓
调用 POST /api/ai/parse-order { input_type: "image", image_url }
  ↓
展示解析结果 → 确认填充

5.5 前端新增文件

frontend/web-sales/src/
  ├── mockApi.js           (新增 parseOrderText / parseOrderImage / createUploadToken 方法)
  └── views/
      └── OrderFormPage.vue (在现有表单顶部插入智能填单组件)

5.6 mockApi 新增方法

// mockApi.js 新增

export async function createUploadToken(payload) {
  return request("/api/files/upload-token", {
    method: "POST",
    body: JSON.stringify(payload),
  });
}

export async function parseOrderText(text) {
  return request("/api/ai/parse-order", {
    method: "POST",
    body: JSON.stringify({ input_type: "text", text }),
  });
}

export async function parseOrderImage(imageUrl) {
  return request("/api/ai/parse-order", {
    method: "POST",
    body: JSON.stringify({ input_type: "image", image_url: imageUrl }),
  });
}

5.8 防重复提交与超时处理

业务员可能快速点击"智能解析"按钮,或网络慢时反复点击。前端需要防抖和超时处理:

let parseTimer = null;

async function handleParseSubmit() {
  if (parseLoading.value) return;  // 正在解析中,忽略重复点击

  // 防抖300ms 内只触发一次
  clearTimeout(parseTimer);
  parseTimer = setTimeout(async () => {
    parseLoading.value = true;
    message.value = "";
    try {
      const result = await parseOrderText(parseInput.value);
      parsedResult.value = result;
      // 初始化可编辑副本
      parseEditable.value = {
        customer_name: result.parsed_order.customer_name || "",
        customer_mobile: result.parsed_order.customer_mobile || "",
        customer_address: result.parsed_order.customer_address || "",
        order_source: result.parsed_order.order_source || "",
        delivery_type: result.parsed_order.delivery_type || "",
        remark: result.parsed_order.remark || "",
        items: (result.parsed_order.items || []).map((item, idx) => ({
          ...item,
          _selectedCandidate: result.product_matches?.[idx]?.candidates?.[0] || null,
        })),
      };
    } catch (error) {
      message.value = error.message || "解析失败";
      messageType.value = "error";
    } finally {
      parseLoading.value = false;
    }
  }, 300);
}

5.9 演示模式标识

前端根据 is_mock 字段展示提示:

const isMockMode = computed(() => parsedResult.value?.is_mock === true);

模板中:

<span v-if="isMockMode" class="mode-tag fallback">演示数据</span>

5.10 OrderFormPage.vue 完整改动清单

区域 改动内容
template <form> 新增智能填单卡片(文本/图片 Tab + 解析结果预览 + 确认填入按钮)
template 产品明细 每行产品下方增加匹配候选展示(来自 product_matches
script 新增 ref parseMode, parseInput, parseImageFile, parseImagePreview, parsedResult, parseLoading, parseEditable
script 新增方法 handleParseSubmit() (含 debounce), handleParseImage() (含 OSS 上传), handleConfirmParse() (核心映射逻辑), handleProductCandidateSelect()
script 复用 buildDefaultItem(), handleCustomerChange() 现有方法
style 新增 智能填单卡片、解析预览、产品候选列表样式

六、配置项

6.1 环境变量

config.py 新增:

# LLM 解析
llm_parse_enabled: bool = Field(default=True, alias="LLM_PARSE_ENABLED")
llm_parse_model: str = Field(default="qwen-plus", alias="LLM_PARSE_MODEL")
llm_parse_api_url: str = Field(
    default="https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
    alias="LLM_PARSE_API_URL",
)

使用项目已有的 aliyun_ai_access_key_id 作为 API Key不需要新增密钥配置。

6.2 功能开关

LLM_PARSE_ENABLED=false 时退化为纯正则模式,方便开发调试和成本控制。


七、安全与风控

7.1 接口安全

  • 复用现有 JWT 鉴权和权限校验(require_roles("salesman", "admin")
  • 图片 URL 必须是系统 OSS 域名下的,防止 SSRF
  • LLM 调用有 30 秒超时,防止阻塞

7.2 数据安全

  • 解析结果不落库,仅在前端会话内使用
  • LLM 调用的文本不包含价格、成本等敏感商业数据(这些字段 LLM 层返回 null由业务员手动填写或从产品库匹配
  • OCR 识别结果如需落库,复用现有 ai_repository 的日志表

7.3 成本控制

  • 正则层免费LLM 层按 token 计费
  • qwen-plus 价格约 0.8 元/百万 token单次解析约 500 token成本极低
  • 建议加频率限制:同一用户每分钟最多 10 次解析请求

八、实施计划

Phase 1后端核心3天

步骤 内容 验证方式
1.1 schemas/ai.py 新增 ParseOrderRequest / ParseOrderResponse 类型检查通过
1.2 deps.py 新增 get_ai_service 依赖 依赖注入链路正确
1.3 ai_service.py 新增 TextPreprocessor(文本清洗) 单测:传入含微信时间戳的文本,验证清洗结果
1.4 ai_service.py 新增 RuleExtractor 单测:传入示例文本,验证正则提取结果
1.5 ai_service.py 新增 OrderOCRParserOCR 版面分析) 单测:构造 line_list验证版面分区和字段提取
1.6 ai_service.py 新增 LLMOrderParser 类(含 safe_parse、OCR 上下文 prompt 单测mock dashscope 响应 + 模拟 LLM 失败场景
1.7 ai_service.py 新增 parse_order() 方法含文本预处理、demo fallback、客户匹配、规格级产品匹配、工厂选项 单测:文本输入 + 图片输入 + LLM 失败退化
1.8 api/ai.py 新增 POST /api/ai/parse-order 端点(权限:salesman + ai:parse-order curl 调用测试
1.9 demo_store.pysales01 用户权限列表补充 ai:parse-order 登录业务员后能调通接口

Phase 2前端填单2天

步骤 内容 验证方式
2.1 mockApi.js 新增 parseOrderText / parseOrderImage 控制台调用验证
2.2 OrderFormPage.vue 增加智能填单卡片(文本粘贴 Tab 页面展示正常,粘贴文本能调接口
2.3 OrderFormPage.vue 增加图片上传 Tab 图片上传 + 调接口成功
2.4 OrderFormPage.vue 增加解析结果确认预览层 解析结果正确展示,可编辑
2.5 OrderFormPage.vue 实现确认填入逻辑 确认后表单字段正确填充

Phase 3产品匹配优化1天

步骤 内容 验证方式
3.1 前端产品模糊匹配逻辑 输入"精品A"能匹配到"精品A型"
3.2 匹配候选交互(点击关联、手动覆盖) 选择候选后 product_id 正确写入
3.3 无匹配场景的处理 保留原始输入,标记为新产品

Phase 4联调与打磨1天

步骤 内容 验证方式
4.1 端到端流程联调 文本粘贴 → 解析 → 确认 → 填充 → 提交
4.2 图片识别端到端 拍照 → 上传 → OCR → 解析 → 确认 → 填充
4.3 异常场景覆盖 空文本、模糊图片、LLM 超时、产品无匹配

总工期预估7 个工作日


九、设计审查:与现有系统的适配问题

以下是在逐文件比对现有代码后发现的适配问题,已全部纳入方案。

9.1 权限模型:业务员需要新增 AI 解析权限

问题:现有 api/ai.pyrecognize 端点限制 require_roles("manager", "admin"),业务员(salesman)无权调用。demo_store 中业务员的 permissions 列表也不包含 ai:recognize

解决

  • 新增权限码 ai:parse-order,独立于现有的 ai:recognize(后者是管理员用于修正识别结果的,语义不同)
  • demo_store.pysales01 用户权限列表中补充 ai:parse-order
  • 新端点 POST /api/ai/parse-order 使用 require_roles("salesman", "admin") + require_permissions("ai:parse-order")
# api/ai.py 新增端点的权限设置
@router.post("/parse-order")
def parse_order(
    payload: ParseOrderRequest,
    ai_service: AIService = Depends(get_ai_service),
    session: Session = Depends(get_db_session),
    current_user: dict = Depends(require_roles("salesman", "admin")),
    _permission_user: dict = Depends(require_permissions("ai:parse-order")),
) -> dict:
    return success_payload(ai_service.parse_order(payload.model_dump(), session))

同时需要在 deps.py 中新增 get_ai_service 依赖(现有 ai.py 直接用了模块级单例,但按项目其他模块的模式应该走依赖注入):

# deps.py 新增
from backend.app.services.ai_service import ai_service

def get_ai_service():
    return ai_service

9.2 Demo Fallback 模式parse_order 需要兼容演示数据

问题:现有所有 service 方法都遵循 try real DB → except → demo_store fallback 模式。parse_order 在无数据库或 LLM 未配置时也应该能工作。

解决

def parse_order(self, payload: dict, session: Session | None = None) -> dict:
    # ... 真实解析逻辑 ...

    except Exception:
        # LLM 调用失败或 OCR 失败时,返回基于规则的部分结果
        pass

    # 无 session 或 LLM 未启用时,返回模拟解析结果
    return {
        "parsed_order": {
            "customer_name": "演示客户",
            "customer_mobile": "13900000000",
            "customer_address": "杭州市西湖区演示地址 1 号",
            "items": [{"product_name": "演示产品A", "specification": "10kg", "unit": "吨", "quantity": 1, "sale_price": 100}],
        },
        "raw_text": payload.get("text", "") or "[图片演示]",
        "confidence": 0.85,
        "ocr_confidence": None,
        "parse_source": "demo",
        "product_matches": [],
        "warnings": ["演示模式:此为模拟解析结果"],
    }

9.3 产品数据结构:规格维度匹配

问题:现有产品是按 product_name 分组、每个名称下有多条 specification 行的结构。_group_products 返回的格式是:

{
  "product_name": "演示产品A",
  "product_id": 2001,
  "specifications": [
    { "product_id": 2001, "specification": "10kg", "unit": "吨", "cost_price": 60, "sale_price": 100 }
  ]
}

设计中的产品匹配只按 product_name 做模糊匹配,遗漏了规格维度。实际业务员说"要 A 产品 25kg 的",需要同时匹配产品名和规格。

解决_match_products 改为按 product_name + specification 组合匹配,匹配结果中同时返回 product_id(规格行 ID、标准价格和规格信息前端可以直接关联到具体规格行。

def _match_products(self, items: list[dict],
                    product_groups: list[dict],
                    session: Session | None) -> list[dict]:
    """按 product_name + specification 双维度匹配。"""
    from difflib import SequenceMatcher

    # 展平产品组:每个 (product_name, specification) 作为候选
    flat_products = []
    for group in product_groups:
        for spec in group.get("specifications", []):
            flat_products.append({
                "product_id": spec["product_id"],
                "product_name": group["product_name"],
                "specification": spec["specification"],
                "unit": spec["unit"],
                "sale_price": spec.get("sale_price", 0),
                "cost_price": spec.get("cost_price", 0),
            })

    matches = []
    for item in items:
        input_name = item.get("product_name", "")
        input_spec = item.get("specification", "")
        if not input_name:
            continue

        scored = []
        for prod in flat_products:
            # 产品名相似度
            name_score = SequenceMatcher(None, input_name, prod["product_name"]).ratio()
            # 规格相似度
            spec_score = SequenceMatcher(None, input_spec, prod["specification"]).ratio() if input_spec else 0
            # 综合得分:产品名权重 0.6,规格权重 0.4
            combined = name_score * 0.6 + spec_score * 0.4
            scored.append((prod, combined))

        scored.sort(key=lambda x: x[1], reverse=True)
        matches.append({
            "input_name": input_name,
            "input_specification": input_spec,
            "candidates": [
                {
                    "product_id": prod["product_id"],
                    "product_name": prod["product_name"],
                    "specification": prod["specification"],
                    "unit": prod["unit"],
                    "sale_price": prod["sale_price"],
                    "cost_price": prod["cost_price"],
                    "match_score": round(score, 2),
                }
                for prod, score in scored[:3] if score > 0.3
            ],
        })
    return matches

对应的 _get_product_names 也应改为获取完整的产品组数据,而非仅名称列表:

def _get_product_groups(self, session: Session | None) -> list[dict]:
    """获取按名称分组的产品列表(含规格),复用 ProductService 的分组逻辑。"""
    if session is not None:
        try:
            from backend.app.services.product_service import product_service
            result = product_service.list_products({}, session)
            return result.get("list", [])
        except Exception:
            pass
    # 演示数据
    return [
        {
            "product_name": "演示产品A",
            "product_id": 2001,
            "specifications": [
                {"product_id": 2001, "specification": "10kg", "unit": "吨", "sale_price": 100, "cost_price": 60}
            ],
        },
        {
            "product_name": "演示产品B",
            "product_id": 2002,
            "specifications": [
                {"product_id": 2002, "specification": "20kg", "unit": "吨", "sale_price": 180, "cost_price": 120}
            ],
        },
    ]

9.4 LLM Prompt 应注入真实产品库

问题:之前的 prompt 中传入的是 product_names(纯名称列表),现在改为传入分组数据。同时 prompt 中应该把规格信息也告诉 LLM让它能匹配更精确。

解决

# LLMOrderParser 的 prompt 中替换 product_names 注入方式
# 之前:
#   product_names=['演示产品A', '演示产品B']
# 现在:
product_hints = []
for group in product_groups[:30]:  # 限制 30 组避免 prompt 过长
    specs = ', '.join(
        f"{s['specification']}({s['unit']})"
        for s in group.get("specifications", [])[:5]
    )
    product_hints.append(f"- {group['product_name']}: {specs}")

# 注入 prompt
# 可选的产品库(名称 + 规格):
# - 演示产品A: 10kg(吨)
# - 演示产品B: 20kg(吨)

9.5 客户匹配:利用现有 find_by_mobile

问题:解析出手机号后,应该优先匹配现有客户库,复用 CustomerRepository.find_by_mobile。这样解析结果中可以附带 customer_id,前端确认时直接关联已有客户,避免重复创建。

解决:在 parse_order 中,解析出 customer_mobile 后查询客户库:

# parse_order 中新增
if session is not None and merged.get("customer_mobile"):
    try:
        existing_customer = self.customer_repository.find_by_mobile(
            session, merged["customer_mobile"]
        )
        if existing_customer:
            merged["customer_id"] = existing_customer.id
            # 客户库有更完整的地址时,补充解析结果
            if existing_customer.address and not merged.get("customer_address"):
                merged["customer_address"] = existing_customer.address
            warnings.append(f"已匹配到客户库中的客户ID: {existing_customer.id}")
    except Exception:
        pass

响应中也相应增加 customer_id 字段(null 表示新客户)。

9.6 图片上传 OSS 流程的前端细节

问题:设计中提到了 OSS 上传但没有给出具体的前端调用序列。实际项目中 OSS 上传是先获取凭证再直传的两步流程。

解决:图片上传 Tab 的完整前端流程:

// OrderFormPage.vue 中 handleParseImage 的完整流程
async function handleParseImage(file) {
  parseLoading.value = true;
  try {
    // 1. 获取上传凭证(复用现有 mockApi 中的 upload-token 流程)
    const tokenData = await createUploadToken({
      file_name: file.name,
      file_type: file.type,
      file_size: file.size,
      biz_type: "order_parse",
      biz_id: 0,  // 此时尚未创建订单
    });

    // 2. 直传 OSS
    const formData = new FormData();
    formData.append("key", tokenData.object_key);
    formData.append("policy", tokenData.policy);
    formData.append("OSSAccessKeyId", tokenData.access_key_id);
    formData.append("signature", tokenData.signature);
    formData.append("file", file);

    await fetch(tokenData.upload_url, { method: "POST", body: formData });

    // 3. 拿到图片 URL调用解析接口
    const imageUrl = `${tokenData.public_base_url}/${tokenData.object_key}`;
    const result = await parseOrderImage(imageUrl);

    // 4. 展示解析结果
    parsedResult.value = result;
    previewMode.value = true;
  } catch (error) {
    message.value = error.message || "图片识别失败";
    messageType.value = "error";
  } finally {
    parseLoading.value = false;
  }
}

同时需要在 mockApi.js 中补充 createUploadToken 方法(现有文件中未导出此方法):

// mockApi.js 新增
export async function createUploadToken(payload) {
  return request("/api/files/upload-token", {
    method: "POST",
    body: JSON.stringify(payload),
  });
}

9.7 工厂列表预填充

问题:订单表单需要选择工厂(factory_id),但设计的解析结果中没有包含工厂信息。如果业务员在文本中提到了工厂名,应该尝试匹配。

解决:在 parse_order 中,如果 LLM 识别出了 order_source 或类似工厂的关键词,可以查询工厂列表做匹配。但考虑到工厂选择是低频操作且工厂数量通常很少(<20这个优先级较低——第一版可以让 LLM 在 remark 或 order_source 中提取工厂相关信息,但不做自动匹配。

# parse_order 中新增(可选,优先级低)
if session is not None and llm_result.get("delivery_type"):
    try:
        suppliers = self._get_factory_list(session)
        for factory in suppliers:
            if factory["supplier_name"] in (llm_result.get("delivery_type") or ""):
                merged["factory_id"] = factory["value"]
                break
    except Exception:
        pass

9.8 is_mock 标识透传

问题:前端 mockApi.js 的请求函数在 demo fallback 模式下会在返回数据中附加 isMock: true,前端据此展示"演示数据"标签。新接口也应遵循此模式。

解决parse_order 在 demo fallback 返回时标记 is_mock: true,前端根据此字段切换提示文案。

9.9 响应 Schema 完善:增加 customer_idfactory_options

综合以上修正,完整的响应结构:

{
  "code": 0,
  "data": {
    "parsed_order": {
      "customer_id": null,
      "customer_name": "张三",
      "customer_mobile": "13812345678",
      "customer_address": "杭州市西湖区文一路100号",
      "order_source": null,
      "delivery_type": null,
      "factory_id": null,
      "remark": "周五前要送到",
      "items": [
        {
          "product_name": "A产品",
          "product_id": null,
          "specification": "25kg",
          "unit": "吨",
          "quantity": 200,
          "sale_price": 85,
          "cost_price": null
        }
      ]
    },
    "raw_text": "张三 13812345678 ...",
    "confidence": 0.88,
    "ocr_confidence": 0.92,
    "parse_source": "hybrid",
    "is_mock": false,
    "product_matches": [
      {
        "input_name": "A产品",
        "input_specification": "25kg",
        "candidates": [
          {
            "product_id": 2001,
            "product_name": "精品A型",
            "specification": "25kg",
            "unit": "吨",
            "sale_price": 85,
            "cost_price": 60,
            "match_score": 0.85
          }
        ]
      }
    ],
    "factory_options": [
      { "value": 1001, "label": "工厂A" }
    ],
    "warnings": ["未识别到订单来源"]
  }
}

customer_id 非 null 时表示已匹配到现有客户,前端确认时直接关联。 product_matches 中每个候选项包含完整的 product_id、规格和价格,业务员选中后可以直接写入表单。 factory_options 为当前可用工厂列表,供业务员在解析结果中选择。


十、扩展考虑(后续迭代)

以下内容不在本次实施范围内,但架构上已预留扩展空间:

  • 历史订单复用:解析时优先匹配该客户的历史订单模板,产品列表直接从历史订单中拉取
  • 多语言/方言LLM 天然支持prompt 层加说明即可
  • 语音输入:接入 ASR 服务,先转文本再走解析流程
  • 批量导入:支持 Excel/CSV 批量解析,复用正则层逻辑
  • 解析结果学习:把业务员确认后的解析结果回流,微调 prompt 提升准确率