陆运选项不全时改为复制填空清单,方便销售按编号回填。

停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 17:10:13 +08:00
co-authored by Cursor
parent 2334524bb8
commit 378bb892b3
33 changed files with 1204 additions and 265 deletions
+134 -18
View File
@@ -11,27 +11,43 @@ import logging
import re
from typing import Any, Optional
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
logger = logging.getLogger(__name__)
# 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。
_QUOTE_DATE_LABEL_RE = "|".join(
re.escape(name)
for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True)
)
_MODE_WORDS = (
("空运", "AIR"),
("海运", "SEA"),
("陆运", "LAND"),
)
# 未定运输方式时:整句只回 1/2/3 对应陆运/海运/空运。10件、1.件数 不算。
_MODE_INDEX = re.compile(r"^\s*([1231-3])\s*[\.、.]?\s*$")
_MODE_BY_INDEX = {
"1": "LAND",
"2": "SEA",
"3": "AIR",
"1": "LAND",
"2": "SEA",
"3": "AIR",
}
# 仅「键:值」一行一条,键必须是合同字段或展示名
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
# 仅「键:值」一行一条,键必须是合同字段或展示名。报价日期含询价/走货/发货等同义词。
_LABEL_KEYS = (
r"起运地|起运港|目的地|目的港|品名|件数|毛重|体积(CBM)|体积\(CBM\)|体积|重量(KG)|重量\(KG\)|重量|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|"
+ _QUOTE_DATE_LABEL_RE
+ r"|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸"
)
_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$")
_LABEL_INLINE = re.compile(rf"({_LABEL_KEYS})\s*[::]\s*(\S+)")
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
_OPTIONAL_INQUIRY_LABELS = (
"通关口岸(非必填)",
@@ -52,6 +68,48 @@ _OPTIONAL_INQUIRY_LABELS = (
)
# 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。
_NUMBERED_ITEM = re.compile(r"(?m)^\s*\d+[\.、.]\s+\S")
# 补问复制清单:1.件数(必填):10 ;下一行 (如:10)不算值。
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
_CLARIFY_INDEX = re.compile(r"^\s*\d+[\.、.]\s*")
_CLARIFY_DECOR = re.compile(r"((?:KG|CBM|kg|cbm|必填|非必填))|\((?:KG|CBM|必填|非必填)\)", re.I)
_CLARIFY_NAMES = frozenset(
{
"起运地",
"起运港",
"目的地",
"目的港",
"品名",
"货物品名",
"件数",
"毛重",
"体积(CBM)",
"体积",
"重量(KG)",
"重量",
"包装方式",
"包装类型",
"货量",
"货物数量",
"整柜或拼柜",
"箱型箱量",
"报价日期",
*QUOTE_DATE_SYNONYMS,
"运输分类",
"运输方式",
"运输类型",
"线路类别",
"车型/数量",
"车型数量",
"贸易条款",
"货源地",
"货好时间",
"提货地址",
"收货地址",
"送货地址",
"通关口岸",
"始发站",
}
)
# 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。
_SHEET_CLEAN_KEYS = frozenset(
{
@@ -62,7 +120,10 @@ _SHEET_CLEAN_KEYS = frozenset(
"品名",
"件数",
"毛重",
"体积(CBM)",
"体积",
"重量(KG)",
"重量",
"货量",
"货物数量",
"整柜或拼柜",
@@ -70,6 +131,7 @@ _SHEET_CLEAN_KEYS = frozenset(
"贸易条款",
"运输分类",
"报价日期",
*QUOTE_DATE_SYNONYMS,
"包装方式",
"包装类型",
}
@@ -121,7 +183,9 @@ def detect_transport_mode(text: str) -> str:
"""
运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。
未定方式时整句回 1/2/3 分别是陆运/海运/空运;「1.陆运」仍按文字认。
写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。
「10件」「1.件数」不是运输方式。
"""
raw = text or ""
found = [code for word, code in _MODE_WORDS if word in raw]
@@ -129,30 +193,79 @@ def detect_transport_mode(text: str) -> str:
return found[0]
if len(found) > 1:
return ""
hit = _MODE_INDEX.match(raw.strip())
if hit:
return _MODE_BY_INDEX.get(hit.group(1), "")
if "飞" in raw:
return "AIR"
return ""
def parse_clarify_paste_facts(text: str) -> dict[str, str]:
"""
认补问复制清单:编号 + 字段名 +(单位/必填)+ 冒号后的值。
「(如:10)」整行丢掉。冒号后为空也不收,避免把示例当成已填。
不猜港口,不改六态。
"""
facts: dict[str, str] = {}
for line in (text or "").splitlines():
raw = (line or "").strip()
if not raw or _CLARIFY_EXAMPLE_LINE.match(raw):
continue
raw = _CLARIFY_INDEX.sub("", raw, count=1)
if ":" not in raw and ":" not in raw:
continue
if ":" in raw:
name, val = raw.split(":", 1)
else:
name, val = raw.split(":", 1)
name = _CLARIFY_DECOR.sub("", name).strip()
val = (val or "").strip()
if not name or name not in _CLARIFY_NAMES:
continue
if not val or _CLARIFY_EXAMPLE_LINE.match(val):
continue
facts[name] = clip_inquiry_value(val)
return {key: val for key, val in facts.items() if val}
def parse_labeled_facts(text: str) -> dict[str, str]:
"""
只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。
补问复制清单(1.件数(必填):10)优先。
报价单常把表头和格子写在一行(起运港/POL(必填) 纽约港),
冒号对不上时再按表头切一刀,值只留格子。
"""
facts: dict[str, str] = {}
raw = text or ""
facts: dict[str, str] = parse_clarify_paste_facts(text)
raw = "\n".join(
line
for line in (text or "").splitlines()
if not _CLARIFY_EXAMPLE_LINE.match(line.strip())
)
for line in raw.splitlines():
m = _LABEL_LINE.match(line.strip())
if m:
shown = line.strip()
m = _LABEL_LINE.match(shown)
if m and not str(facts.get(m.group(1)) or "").strip():
facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip())
if not facts:
for m in _LABEL_INLINE.finditer(raw):
facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip())
from agent.schema.field_validate import normalize_facts
taken = set(normalize_facts(facts))
for key, val in harvest_sheet_inquiry_cells(raw).items():
if val and not str(facts.get(key) or "").strip():
facts[key] = val
if not val:
continue
mapped = next(iter(normalize_facts({key: val})), "")
if mapped and mapped in taken:
continue
if str(facts.get(key) or "").strip():
continue
facts[key] = val
if mapped:
taken.add(mapped)
return {key: val for key, val in facts.items() if val}
@@ -287,6 +400,9 @@ def clip_inquiry_value(raw: str) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip()
if _CLARIFY_EXAMPLE_LINE.match(text):
return ""
hit = _NUMBERED_ITEM.search(text)
if hit and hit.start() > 0:
text = text[: hit.start()]
@@ -384,7 +500,7 @@ def _label_is_token(raw: str, pos: int, label: str) -> bool:
return False
end = pos + len(label)
nxt = raw[end : end + 6]
if label in {"重量", "体积"} and nxt.startswith("("):
if label in {"重量", "体积"} and (nxt.startswith("(") or nxt.startswith("(")):
return False
return True