加入业务员快速处理信息

This commit is contained in:
wsb1224 2026-05-26 17:20:21 +08:00
parent fe25444955
commit 1c8ae36203
9 changed files with 2733 additions and 3 deletions

View File

@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
from backend.app.api.deps import require_permissions, require_roles
from backend.app.db import get_db_session
from backend.app.schemas.ai import CorrectRecognizeResultRequest, RecognizeImageRequest
from backend.app.schemas.ai import CorrectRecognizeResultRequest, ParseOrderRequest, RecognizeImageRequest
from backend.app.schemas.common import success_payload
from backend.app.services.ai_service import ai_service
@ -29,3 +29,13 @@ def correct_recognize_result(
_permission_user: dict = Depends(require_permissions("ai:correct")),
) -> dict:
return success_payload(ai_service.correct_result(log_id, payload.model_dump(), session))
@router.post("/parse-order")
def parse_order(
payload: ParseOrderRequest,
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))

View File

@ -15,6 +15,7 @@ from backend.app.services.reminder_service import reminder_service
from backend.app.services.report_service import report_service
from backend.app.services.supplier_service import supplier_service
from backend.app.services.system_service import system_service
from backend.app.services.ai_service import ai_service
# 统一依赖入口,后续如果切换到真实容器或数据库实现,只需要改这里。
@ -94,3 +95,7 @@ def get_audit_service():
def get_system_service():
return system_service
def get_ai_service():
return ai_service

View File

@ -57,6 +57,12 @@ class Settings(BaseSettings):
wechat_template_logistics_timeout: str = Field(default="", alias="WECHAT_TEMPLATE_LOGISTICS_TIMEOUT")
wechat_template_arrears: str = Field(default="", alias="WECHAT_TEMPLATE_ARREARS")
wechat_template_inactive_customer: str = Field(default="", alias="WECHAT_TEMPLATE_INACTIVE_CUSTOMER")
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",
)
@property
def cors_origins(self) -> list[str]:

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class RecognizeImageRequest(BaseModel):
@ -9,3 +9,17 @@ class RecognizeImageRequest(BaseModel):
class CorrectRecognizeResultRequest(BaseModel):
corrected_result: dict
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

View File

@ -1,5 +1,7 @@
import json
import re
from decimal import Decimal
from difflib import SequenceMatcher
from urllib import error, parse, request
from sqlalchemy.exc import SQLAlchemyError
@ -12,6 +14,345 @@ from backend.app.repositories.ai_repository import AIRepository
from backend.app.services.audit_service import audit_service
# ---------------------------------------------------------------------------
# 智能填单:文本预处理
# ---------------------------------------------------------------------------
class TextPreprocessor:
"""清洗粘贴文本中的噪声,保留有效订单信息。"""
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
result = self.TIMESTAMP_RE.sub('', result)
result = self.BRACKET_TIME_RE.sub('', result)
for marker in self.NOISE_MARKERS:
result = result.replace(marker, '')
result = self.USERNAME_PREFIX_RE.sub('', result)
result = re.sub(r'\n{3,}', '\n\n', result)
return result.strip()
# ---------------------------------------------------------------------------
# 智能填单:规则提取层
# ---------------------------------------------------------------------------
class RuleExtractor:
"""基于规则的字段提取器,处理确定性高的字段。"""
PHONE_RE = re.compile(r'1[3-9]\d{9}')
QTY_UNIT_RE = re.compile(
r'(\d+\.?\d*)\s*(吨|件|箱|包|个|米|kg|KG|公斤|斤|卷|组|套|台|条|根|片|块)'
)
PRICE_RE = re.compile(r'(?:单价|价格|报价)\s*[:]?\s*(\d+\.?\d*)')
ADDRESS_KEYWORDS = [
'', '', '', '', '', '', '', '',
'', '', '', '单元', '', '大厦', '广场',
]
def extract(self, text: str) -> dict:
result: dict = {}
phones = self.PHONE_RE.findall(text)
if phones:
result['customer_mobile'] = phones[0]
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))
addr = self._extract_address(text)
if addr:
result['customer_address'] = addr
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
# ---------------------------------------------------------------------------
# 智能填单OCR 版面分析
# ---------------------------------------------------------------------------
class OrderOCRParser:
"""基于 OCR 行列表做订单版面分析。"""
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:
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: dict[str, list[int]] = {
"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: dict = {}
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 = [lines[i].strip() for i in zones.get("address_lines", [])]
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: list[dict] = []
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: dict = {"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
# ---------------------------------------------------------------------------
# 智能填单LLM 结构化解析
# ---------------------------------------------------------------------------
class LLMOrderParser:
"""调用 dashscope qwen-plus 进行订单文本结构化解析。"""
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_hints}"""
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_hints}"""
def _build_product_hints(self, product_groups: list[dict]) -> str:
hints: list[str] = []
for group in product_groups[:30]:
specs = ', '.join(
f"{s['specification']}({s['unit']})"
for s in group.get("specifications", [])[:5]
)
hints.append(f"- {group['product_name']}: {specs}")
return '\n'.join(hints) or "(产品库为空)"
def parse(self, text: str, product_groups: list[dict],
api_key: str, api_url: str,
ocr_context: dict | None = None) -> dict:
product_hints = self._build_product_hints(product_groups)
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_hints=product_hints,
)
else:
system_prompt = self.TEXT_SYSTEM_PROMPT.format(product_hints=product_hints)
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:
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:
try:
return self.parse(text, product_groups, api_key, api_url, ocr_context)
except Exception:
return None
# 避免和后面 urllib_request 冲突,这里在文件顶部已经 import 了
urllib_request = request
class BaseOCRProvider:
provider_name = "base"
@ -292,5 +633,253 @@ class AIService:
except json.JSONDecodeError:
return value
# ------------------------------------------------------------------
# 智能填单:核心解析入口
# ------------------------------------------------------------------
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 结构化解析
settings = self.settings
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. 合并
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:
from backend.app.repositories.customer_repository import CustomerRepository
customer_repo = CustomerRepository()
existing_customer = customer_repo.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:
provider = self._build_provider()
raw_result, suggested_result, ocr_confidence = provider.recognize(
image_url, "order_parse", 0
)
line_list = suggested_result.get("line_list", [])
if not line_list:
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:
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 _build_fallback_result(self, rule_result: dict) -> dict:
items: list[dict] = []
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]:
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 _match_products(self, items: list[dict],
product_groups: list[dict],
session: Session | None) -> list[dict]:
flat_products: list[dict] = []
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: list[dict] = []
for item in items:
input_name = item.get("product_name", "")
input_spec = item.get("specification", "")
if not input_name:
continue
scored: list[tuple] = []
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
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
def _validate_parsed_order(self, order: dict) -> list[str]:
warnings: list[str] = []
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:
base = 1.0
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 _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"}]
ai_service = AIService()

View File

@ -14,7 +14,7 @@ class DemoStore:
"role_code": "salesman",
"token": "demo-sales-token",
"menus": [{"menu_name": "我的订单", "menu_path": "/orders"}],
"permissions": ["order:create", "order:list", "order:submit"],
"permissions": ["order:create", "order:list", "order:submit", "ai:parse-order"],
},
("admin01", "admin"): {
"user_id": 99,

File diff suppressed because it is too large Load Diff

View File

@ -346,3 +346,28 @@ export async function runArrearsCheck() {
export async function runInactiveCustomerCheck() {
return request("/api/reminders/inactive-customers/check", { method: "POST" });
}
// ---------------------------------------------------------------------------
// 智能填单
// ---------------------------------------------------------------------------
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 }),
});
}

View File

@ -16,6 +16,106 @@
{{ message }}
</div>
<!-- 智能填单 -->
<section v-if="!parsedResult" class="form-section parse-card">
<div class="section-header">
<h3>智能填单</h3>
<span v-if="isMockMode" class="mode-tag fallback">演示数据</span>
</div>
<div class="parse-tabs">
<button type="button" :class="{ active: parseMode === 'text' }" @click="parseMode = 'text'">粘贴文本</button>
<button type="button" :class="{ active: parseMode === 'image' }" @click="parseMode = 'image'">拍照/上传图片</button>
</div>
<div v-if="parseMode === 'text'" class="parse-text-area">
<textarea v-model="parseInput" rows="5" placeholder="请粘贴订单相关文本(微信聊天记录、电话记录等)"></textarea>
<div class="parse-actions">
<button type="button" class="primary-btn" :disabled="parseLoading || !parseInput.trim()" @click="handleParseSubmit">
{{ parseLoading ? '解析中...' : '智能解析' }}
</button>
</div>
</div>
<div v-else class="parse-image-area">
<div v-if="parseImagePreview" class="parse-image-preview">
<img :src="parseImagePreview" alt="预览" />
</div>
<label class="ghost-btn parse-upload-label">
选择图片
<input type="file" accept="image/*" capture="environment" style="display:none" @change="handleParseImageChange" />
</label>
<div class="parse-actions">
<button type="button" class="primary-btn" :disabled="parseLoading || !parseImageFile" @click="handleParseImageFile">
{{ parseLoading ? '识别中...' : '上传识别' }}
</button>
</div>
</div>
</section>
<!-- 解析结果预览 -->
<section v-if="parsedResult" class="form-section parse-result-card">
<div class="section-header">
<h3>智能填单 - 解析结果</h3>
<div class="parse-result-meta">
<span v-if="isMockMode" class="mode-tag fallback">演示数据</span>
<span class="confidence-tag">置信度 {{ Math.round(parsedResult.confidence * 100) }}%</span>
</div>
</div>
<div class="parse-result-fields">
<label>
<span>客户姓名</span>
<input v-model="parseEditable.customer_name" type="text" />
</label>
<label>
<span>客户手机号</span>
<input v-model="parseEditable.customer_mobile" type="text" />
</label>
<label>
<span>客户地址</span>
<input v-model="parseEditable.customer_address" type="text" />
</label>
<label>
<span>订单来源</span>
<input v-model="parseEditable.order_source" type="text" placeholder="可选" />
</label>
<label>
<span>配送方式</span>
<input v-model="parseEditable.delivery_type" type="text" placeholder="可选" />
</label>
<label>
<span>备注</span>
<input v-model="parseEditable.remark" type="text" placeholder="可选" />
</label>
</div>
<div v-if="parseEditable.items.length" class="parse-result-items">
<h4>产品明细</h4>
<div v-for="(item, idx) in parseEditable.items" :key="idx" class="parse-item-card">
<div class="parse-item-main">
<strong>{{ item.product_name || '未识别产品' }}</strong>
<span>{{ item.specification || '-' }} / {{ item.unit || '-' }} / {{ item.quantity || 0 }} / 单价 {{ item.sale_price || 0 }}</span>
</div>
<div v-if="parsedResult.product_matches?.[idx]?.candidates?.length" class="parse-item-candidates">
<span class="candidate-label">匹配建议</span>
<button
v-for="(c, ci) in parsedResult.product_matches[idx].candidates"
:key="ci"
type="button"
class="candidate-btn"
:class="{ selected: item._selectedCandidate?.product_id === c.product_id }"
@click="handleProductCandidateSelect(idx, c)"
>
{{ c.product_name }} / {{ c.specification }} ({{ Math.round(c.match_score * 100) }}%)
</button>
</div>
</div>
</div>
<div v-if="parsedResult.warnings?.length" class="parse-warnings">
<p v-for="(w, wi) in parsedResult.warnings" :key="wi">{{ w }}</p>
</div>
<div class="parse-actions">
<button type="button" class="ghost-btn" @click="handleResetParse">重新解析</button>
<button type="button" class="primary-btn" @click="handleConfirmParse">确认填入表单</button>
</div>
</section>
<form class="form-grid" @submit.prevent="handleSubmit">
<section class="form-section">
<div class="section-header">
@ -230,9 +330,12 @@ import { useRouter } from "vue-router";
import {
createOrder,
createUploadToken,
fetchCustomerOptions,
fetchFactoryOptions,
fetchProductOptions,
parseOrderImage,
parseOrderText,
} from "../mockApi";
const router = useRouter();
@ -244,6 +347,22 @@ const customerOptions = ref([]);
const factoryOptions = ref([]);
const productOptions = ref([]);
const selectedCustomerId = ref("");
//
const parseMode = ref("text");
const parseInput = ref("");
const parseImageFile = ref(null);
const parseImagePreview = ref("");
const parsedResult = ref(null);
const parseLoading = ref(false);
const parseEditable = ref({
customer_name: "",
customer_mobile: "",
customer_address: "",
order_source: "",
delivery_type: "",
remark: "",
items: [],
});
function buildDefaultItem() {
return {
rowKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@ -355,6 +474,150 @@ function handleCustomerInputBlur() {
customerHint.value = form.auto_sync_customer ? '系统将把当前客户视为新客户,并在提交后自动同步到客户库。' : '当前客户未在客户库中匹配到记录,请确认是否需要手动新增。';
}
// ---------------------------------------------------------------------------
//
// ---------------------------------------------------------------------------
let parseTimer = null;
const isMockMode = computed(() => parsedResult.value?.is_mock === true);
async function handleParseSubmit() {
if (parseLoading.value || !parseInput.value.trim()) return;
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;
}
}
async function handleParseImageFile() {
const file = parseImageFile.value;
if (!file) return;
parseLoading.value = true;
message.value = "";
try {
const tokenData = await createUploadToken({
file_name: file.name,
file_type: file.type,
file_size: file.size,
biz_type: "order_parse",
biz_id: 0,
});
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 });
const imageUrl = `${tokenData.public_base_url}/${tokenData.object_key}`;
const result = await parseOrderImage(imageUrl);
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;
}
}
function handleParseImageChange(event) {
const file = event.target.files?.[0];
if (!file) return;
parseImageFile.value = file;
parseImagePreview.value = URL.createObjectURL(file);
}
function handleConfirmParse() {
const parsed = parseEditable.value;
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 || "";
if (parsedResult.value?.parsed_order?.customer_id) {
const cid = parsedResult.value.parsed_order.customer_id;
selectedCustomerId.value = String(cid);
handleCustomerChange();
} else {
selectedCustomerId.value = "";
}
const newItems = [];
for (const parsedItem of parsed.items) {
const row = buildDefaultItem();
if (parsedItem._selectedCandidate) {
const c = parsedItem._selectedCandidate;
row.selectedProductId = String(c.product_id);
row.product_id = c.product_id;
row.product_name = c.product_name;
row.specification = c.specification;
row.unit = c.unit;
row.sale_price = c.sale_price;
row.cost_price = c.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);
}
items.value = newItems.length > 0 ? newItems : [buildDefaultItem()];
parsedResult.value = null;
parseEditable.value = { customer_name: "", customer_mobile: "", customer_address: "", order_source: "", delivery_type: "", remark: "", items: [] };
}
function handleProductCandidateSelect(itemIndex, candidate) {
if (parseEditable.value.items[itemIndex]) {
parseEditable.value.items[itemIndex]._selectedCandidate = candidate;
}
}
function handleResetParse() {
parsedResult.value = null;
parseEditable.value = { customer_name: "", customer_mobile: "", customer_address: "", order_source: "", delivery_type: "", remark: "", items: [] };
parseInput.value = "";
parseImageFile.value = null;
parseImagePreview.value = "";
}
function fillDemoData() {
if (customerOptions.value.length) {
selectedCustomerId.value = String(customerOptions.value[0].value);
@ -887,4 +1150,34 @@ button:disabled {
gap: 8px;
}
}
/* 智能填单样式 */
.parse-card { border: 2px dashed #93c5fd; background: linear-gradient(180deg, #f0f9ff, #fff); }
.parse-tabs { display: flex; gap: 8px; margin-bottom: 12px; }
.parse-tabs button { border: 1px solid #d1d5db; background: #fff; border-radius: 10px; padding: 8px 16px; cursor: pointer; font-size: 13px; }
.parse-tabs button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.parse-text-area textarea { width: 100%; border: 1px solid #d1d5db; border-radius: 12px; padding: 10px 12px; resize: vertical; font-size: 14px; }
.parse-image-area { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.parse-image-preview { max-width: 200px; border-radius: 10px; overflow: hidden; }
.parse-image-preview img { width: 100%; display: block; }
.parse-upload-label { cursor: pointer; }
.parse-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.parse-result-card { border: 2px solid #2563eb; background: linear-gradient(180deg, #eff6ff, #fff); }
.parse-result-meta { display: flex; gap: 8px; align-items: center; }
.confidence-tag { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; font-weight: 600; }
.mode-tag.fallback { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #fef3c7; color: #92400e; font-size: 12px; font-weight: 600; }
.parse-result-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 14px; }
.parse-result-items { margin-bottom: 14px; }
.parse-result-items h4 { margin: 0 0 8px; font-size: 14px; }
.parse-item-card { padding: 10px; border: 1px solid #e5e7eb; border-radius: 10px; margin-bottom: 8px; background: #f9fafb; }
.parse-item-main { display: flex; flex-direction: column; gap: 4px; }
.parse-item-main strong { font-size: 14px; }
.parse-item-main span { font-size: 12px; color: #6b7280; }
.parse-item-candidates { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; align-items: center; }
.candidate-label { font-size: 12px; color: #6b7280; }
.candidate-btn { border: 1px solid #d1d5db; background: #fff; border-radius: 8px; padding: 4px 10px; font-size: 11px; cursor: pointer; }
.candidate-btn.selected { background: #2563eb; color: #fff; border-color: #2563eb; }
.parse-warnings { margin-bottom: 10px; }
.parse-warnings p { font-size: 12px; color: #b45309; margin: 2px 0; }
</style>