70 KiB
智能填单功能方案设计
一、功能概述
1.1 背景
当前业务员在创建订单时需要手动填写 4 个信息分区(客户信息、订单信息、物流费用、产品明细),字段多且重复录入频繁。业务员日常接收订单的方式主要是微信聊天、电话沟通、拍照转发,信息散落在各种非结构化场景中。
1.2 目标
提供两种快速录入入口,让业务员像顺丰快递员一样"粘贴一下就能出单":
- 文本粘贴解析:把微信聊天记录、电话记录等文本直接粘贴,系统自动识别客户信息和产品明细
- 图片智能识别:拍照或从相册选取订单图片(手写单、名片、聊天截图),系统 OCR 识别后自动填充
1.3 与现有系统的关系
本功能是现有下单流程(OrderFormPage.vue → POST /api/orders)的前置增强,不改变已有订单创建逻辑。解析结果最终仍通过现有的 createOrder 接口提交。
1.3 核心体验流程
业务员打开新建订单页面
↓
看到"智能填单"入口(文本粘贴 / 图片上传 两个 Tab)
↓
输入内容 → 点击"智能解析"
↓
系统返回结构化预览(逐字段展示,标注置信度)
↓
业务员核对、修正(可选)
↓
点击"确认填入" → 自动填充到表单对应字段
↓
业务员补充缺失字段 → 提交订单
二、系统架构
2.1 现有基础设施盘点
| 能力 | 现有位置 | 当前状态 |
|---|---|---|
| OSS 图片上传 | file_service.py / storage_service.py |
完整可用,前端有上传凭证流程 |
| OCR 识别 | ai_service.py → AliyunOCRProvider |
可提取文本行列表和置信度,但当前仅做通用文本提取,无订单场景专项处理 |
| 阿里云 AI 配置 | config.py(endpoint/key/region) |
已配置,可扩展 |
| 订单数据模型 | schemas/orders.py → CreateOrderRequest |
完整,新功能的输出需对齐此结构 |
| 产品库 | 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_list 和 confidence 这两个关键数据在现有流程中被浪费了。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仅在图片输入模式下返回,文本模式下为null。confidence是综合置信度(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_confidence(OCR置信度)│
│ │ - 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]._selectedCandidate 上,UI 上高亮选中项并显示标准价格。这样确认填入时可以直接用候选的价格覆盖解析价格。
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 新增 OrderOCRParser 类(OCR 版面分析) |
单测:构造 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.py 的 sales01 用户权限列表补充 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.py 的 recognize 端点限制 require_roles("manager", "admin"),业务员(salesman)无权调用。demo_store 中业务员的 permissions 列表也不包含 ai:recognize。
解决:
- 新增权限码
ai:parse-order,独立于现有的ai:recognize(后者是管理员用于修正识别结果的,语义不同) - 在
demo_store.py的sales01用户权限列表中补充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_id 和 factory_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 提升准确率