Files
shanxia2.0/backend/app/services/pl_purchase_cert_ocr_service.py
T
wuzilongandCursor d22b06dbe7 feat(pl): 易制毒先安排待确认与购买证管理
含易制毒计划安排后进入已安排待确认,确认通过后再占合同并生成链接/地磅;新增购买证/运输证主数据、产品行关联、余量校验与 OCR 配置,并停止跟踪 api_key/app.pid。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 18:41:10 +08:00

301 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""DeepSeek 视觉识别:易制毒购买证 / 运输证。"""
from __future__ import annotations
import base64
import json
import logging
import mimetypes
import re
from typing import Any, Literal, Optional
import httpx
from fastapi import HTTPException
from app.core.config import settings
from app.services.pl_ocr_service import compress_image_bytes
logger = logging.getLogger(__name__)
CertKind = Literal["purchase", "transport"]
PURCHASE_PROMPT = """你是证件信息提取助手。请识别图片中的「第二类、第三类易制毒化学品购买备案证明」文字,严格输出一个 JSON 对象(不要 Markdown,不要解释)。
须覆盖购买证管理表单全部字段(识别不到填 null):
{
"certNo": "编号",
"issueDate": "核发日期,格式 YYYY-MM-DD",
"buyerName": "【购买单位或个人】名称或姓名",
"buyerLegal": "【购买单位或个人】法定代表人",
"buyerAddr": "【购买单位或个人】住所/地址",
"buyerTel": "【购买单位或个人】电话",
"sellerName": "【销售单位】名称",
"sellerLegal": "【销售单位】法定代表人",
"sellerAddr": "【销售单位】住所",
"sellerTel": "【销售单位】电话",
"productName": "购买物品",
"usageDesc": "用途",
"totalQty": 数量数字(仅阿拉伯数字,不要中文大写),
"unit": "数量单位,如吨",
"validTimes": "有效次数原文,如多次有效",
"validFrom": "有效期限开始日 YYYY-MM-DD",
"validTo": "有效期限结束日 YYYY-MM-DD",
"policeOrg": "公安机关(核发机关全称)",
"handlerName": "经办人",
"policeTel": "公安机关联系电话(若无则 null)"
}
"""
TRANSPORT_PROMPT = """你是证件信息提取助手。请识别图片中的「第三类易制毒化学品运输备案证明」文字,严格输出一个 JSON 对象(不要 Markdown,不要解释)。
须覆盖运输证管理表单全部字段(识别不到填 null):
{
"certNo": "编号",
"issueDate": "核发日期,格式 YYYY-MM-DD",
"ownerName": "【货主】名称或姓名",
"ownerLegal": "【货主】法定代表人",
"ownerAddr": "【货主】住所或住址",
"ownerTel": "【货主】电话",
"productName": "【运输物品】品名",
"packSpec": "【运输物品】包装",
"totalQty": 数量数字(仅阿拉伯数字),
"unit": "数量单位,如吨",
"permitCertNos": "【运输物品】购买许可/备案/出口许可证号,多个用英文逗号分隔",
"carrierName": "【运输单位】名称",
"transportMode": "【运输单位】运输方式",
"carrierContact": "【运输单位】住所/电话(可合并为一串,如 地址/电话)",
"plateNos": "【运输单位】车号(次),多个用顿号或逗号分隔",
"routeDesc": "【运输单位】运输路线",
"consigneeName": "【收货单位】名称/姓名",
"validTimes": "有效次数原文",
"validFrom": "有效期限开始日 YYYY-MM-DD",
"validTo": "有效期限结束日 YYYY-MM-DD",
"applicantUnit": "【申请单位】名称/姓名",
"policeOrg": "公安机关(核发机关全称)",
"handlerName": "经办人",
"policeTel": "公安机关联系电话"
}
"""
def _guess_mime(url: str, raw: bytes) -> str:
lower = (url or "").lower()
if lower.endswith(".png") or raw[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if lower.endswith(".webp"):
return "image/webp"
if lower.endswith(".gif"):
return "image/gif"
guessed, _ = mimetypes.guess_type(url or "")
if guessed and guessed.startswith("image/"):
return guessed
return "image/jpeg"
def _normalize_date(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
if not text or text.lower() == "null":
return None
m = re.search(r"(\d{4})\s*[年./-]\s*(\d{1,2})\s*[月./-]\s*(\d{1,2})", text)
if m:
return f"{int(m.group(1)):04d}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
m2 = re.search(r"^(\d{4})-(\d{2})-(\d{2})$", text)
if m2:
return text
return None
def _normalize_qty(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "")
m = re.search(r"(\d+(?:\.\d+)?)", text)
if not m:
return None
try:
return float(m.group(1))
except ValueError:
return None
def _clean_str(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
if not text or text.lower() == "null":
return None
return text
def _extract_json_object(content: str) -> dict[str, Any]:
text = (content or "").strip()
if not text:
raise HTTPException(status_code=400, detail="识别结果为空,请换清晰图片重试")
fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text, re.I)
if fence:
text = fence.group(1).strip()
try:
data = json.loads(text)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
data = json.loads(text[start : end + 1])
if isinstance(data, dict):
return data
except json.JSONDecodeError:
pass
raise HTTPException(status_code=400, detail="识别结果解析失败,请换清晰图片重试")
def _normalize_purchase(data: dict[str, Any]) -> dict[str, Any]:
qty = _normalize_qty(data.get("totalQty"))
unit = _clean_str(data.get("unit")) or "吨"
return {
"certNo": _clean_str(data.get("certNo")),
"issueDate": _normalize_date(data.get("issueDate")),
"buyerName": _clean_str(data.get("buyerName")),
"buyerLegal": _clean_str(data.get("buyerLegal")),
"buyerAddr": _clean_str(data.get("buyerAddr")),
"buyerTel": _clean_str(data.get("buyerTel")),
"sellerName": _clean_str(data.get("sellerName")),
"sellerLegal": _clean_str(data.get("sellerLegal")),
"sellerAddr": _clean_str(data.get("sellerAddr")),
"sellerTel": _clean_str(data.get("sellerTel")),
"productName": _clean_str(data.get("productName")),
"usageDesc": _clean_str(data.get("usageDesc")),
"totalQty": qty,
"unit": unit,
"validTimes": _clean_str(data.get("validTimes")),
"validFrom": _normalize_date(data.get("validFrom")),
"validTo": _normalize_date(data.get("validTo")),
"policeOrg": _clean_str(data.get("policeOrg")),
"handlerName": _clean_str(data.get("handlerName")),
"policeTel": _clean_str(data.get("policeTel")),
}
def _normalize_transport(data: dict[str, Any]) -> dict[str, Any]:
qty = _normalize_qty(data.get("totalQty"))
unit = _clean_str(data.get("unit")) or "吨"
permit = _clean_str(data.get("permitCertNos"))
if permit:
parts = re.split(r"[,,、;;\s]+", permit)
permit = ",".join(p for p in parts if p)
return {
"certNo": _clean_str(data.get("certNo")),
"issueDate": _normalize_date(data.get("issueDate")),
"ownerName": _clean_str(data.get("ownerName")),
"ownerLegal": _clean_str(data.get("ownerLegal")),
"ownerAddr": _clean_str(data.get("ownerAddr")),
"ownerTel": _clean_str(data.get("ownerTel")),
"productName": _clean_str(data.get("productName")),
"packSpec": _clean_str(data.get("packSpec")),
"totalQty": qty,
"unit": unit,
"permitCertNos": permit,
"carrierName": _clean_str(data.get("carrierName")),
"transportMode": _clean_str(data.get("transportMode")),
"carrierContact": _clean_str(data.get("carrierContact")),
"plateNos": _clean_str(data.get("plateNos")),
"routeDesc": _clean_str(data.get("routeDesc")),
"consigneeName": _clean_str(data.get("consigneeName")),
"validTimes": _clean_str(data.get("validTimes")),
"validFrom": _normalize_date(data.get("validFrom")),
"validTo": _normalize_date(data.get("validTo")),
"applicantUnit": _clean_str(data.get("applicantUnit")),
"policeOrg": _clean_str(data.get("policeOrg")),
"handlerName": _clean_str(data.get("handlerName")),
"policeTel": _clean_str(data.get("policeTel")),
}
def _call_deepseek_vision(*, image_bytes: bytes, mime: str, prompt: str) -> str:
api_key = (settings.DEEPSEEK_API_KEY or "").strip()
if not api_key:
raise HTTPException(status_code=400, detail="DeepSeek 未配置,请联系管理员")
base = (settings.DEEPSEEK_API_BASE or "https://api.deepseek.com").rstrip("/")
model = (settings.DEEPSEEK_VISION_MODEL or "deepseek-v4-flash-vision-exp").strip()
timeout = max(30, int(settings.DEEPSEEK_TIMEOUT_SEC or 120))
b64 = base64.b64encode(image_bytes).decode("ascii")
data_url = f"data:{mime};base64,{b64}"
payload = {
"model": model,
"temperature": 0.1,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": data_url},
},
],
}
],
}
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(
f"{base}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
)
except httpx.TimeoutException as exc:
raise HTTPException(status_code=400, detail="识别超时,请稍后重试") from exc
except Exception as exc:
logger.exception("DeepSeek 请求失败")
raise HTTPException(status_code=400, detail="识别服务调用失败") from exc
if resp.status_code >= 400:
detail = resp.text[:300]
logger.error("DeepSeek HTTP %s: %s", resp.status_code, detail)
raise HTTPException(
status_code=400,
detail=f"识别失败({resp.status_code}),请稍后重试",
)
body = resp.json()
try:
return body["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
logger.error("DeepSeek 响应异常: %s", body)
raise HTTPException(status_code=400, detail="识别响应异常") from exc
def recognize_hazard_cert(*, kind: CertKind, attachment_url: str) -> dict[str, Any]:
url = (attachment_url or "").strip().split(",")[0].strip()
if not url:
raise HTTPException(status_code=400, detail="请先上传证书图片")
if url.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="暂不支持 PDF,请上传证件图片(jpg/png)")
try:
from app.services.bx.storage import download_file_bytes
raw = download_file_bytes(url)
except Exception as exc:
logger.exception("下载证照附件失败: %s", url)
raise HTTPException(status_code=400, detail="附件读取失败,请重新上传后再试") from exc
image_bytes = compress_image_bytes(raw)
mime = _guess_mime(url, image_bytes)
prompt = PURCHASE_PROMPT if kind == "purchase" else TRANSPORT_PROMPT
content = _call_deepseek_vision(image_bytes=image_bytes, mime=mime, prompt=prompt)
data = _extract_json_object(content)
if kind == "purchase":
fields = _normalize_purchase(data)
else:
fields = _normalize_transport(data)
# 完整识别结果以 JSON 字符串返回,供前端回填并落库
fields["ocrResultJson"] = json.dumps(fields, ensure_ascii=False)
return fields