角色业务范围收紧工单与模板;报价日期不当费用;未选舱不迁已报价;另开一票可清违禁 hold

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-15 21:00:01 +08:00
co-authored by Cursor
parent a0c705683f
commit 277cdc1c28
39 changed files with 2083 additions and 217 deletions
+120 -24
View File
@@ -3,6 +3,7 @@
本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。
12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。
服务词后面紧跟的「+数字」(如 TC +1.5、托盘+1)并进同一项,写成 TC+1.5、托盘+1。
包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。
「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。
不挡查价。不改六态。海运、陆运不走这里。
@@ -30,6 +31,8 @@ _CN_RE = re.compile("|".join(re.escape(word) for word in _CN_TERMS))
_MUST_GO_RE = re.compile(r"(?i)MUST\s*GO")
_DG_RE = re.compile(r"(?i)(?<![A-Za-z])DG(?![A-Za-z])")
_TC_RE = re.compile(r"(?i)(?<![A-Za-z])TC(?![A-Za-z])")
# 服务词后的加价/数量:允许空格,记成「词+数字」,不把空格留进销售可见文案。
_QTY_SUFFIX_RE = re.compile(r"[^\S\n]*\+[^\S\n]*(\d+(?:\.\d+)?)")
# 冒号后只吃同一行。\s 会跨行,空着的增值服务会把下一行「报价日期(非必填):」吞进来。
_LABEL_RE = re.compile(
r"增值服务(?:(非必填)|\(非必填\))?[^\S\n]*[::][^\S\n]*([^\n]*)"
@@ -76,12 +79,13 @@ def harvest_added_services(text: str) -> list[str]:
补问示例行「(如:托盘、…)」整行不看,避免清单被当成已填。
「不带电」不算带电;「卡转低消」不算再加一项卡转。
「TC +1.5」记成 TC+1.5,数量跟服务名同一项。
副作用:无。
"""
raw = _without_clarify_examples(text or "")
events: list[tuple[int, str]] = []
# 包装栏里的托盘已在 _canonical_events 排除,这里只收正文里真正的服务词。
events.extend(_canonical_events(raw))
events.extend((start, word) for start, _end, word in _canonical_events(raw))
for match in _LABEL_RE.finditer(raw):
value = (match.group(1) or "").strip()
if not value or value.startswith("(如") or value.startswith("(如"):
@@ -100,7 +104,52 @@ def harvest_added_services(text: str) -> list[str]:
if extra:
events.append((base + at + max(piece.find(extra), 0), extra))
events.sort(key=lambda item: item[0])
return _dedupe([word for _pos, word in events])
result = _dedupe([word for _pos, word in events])
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_tails: list[dict[str, str]] = []
for _m in list(_TC_RE.finditer(raw)) + list(_CN_RE.finditer(raw)):
if _m.group(0) not in {"TC", "tc", "托盘"} and _m.group(0).casefold() != "tc":
continue
_end = _m.end()
_tails.append(
{
"word": _m.group(0),
"after": raw[_end : _end + 12],
}
)
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H1",
"location": "added_services.py:harvest_added_services",
"message": "harvest result vs trailing modifiers",
"data": {
"text_preview": (raw or "")[:120],
"found": result,
"match_tails": _tails,
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _path in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
):
try:
_path.parent.mkdir(parents=True, exist_ok=True)
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
# #endregion
return result
def apply_air_value_added(
@@ -115,6 +164,7 @@ def apply_air_value_added(
只应在空运调用。副作用:返回新字典,不改入参。
"""
merged = dict(facts or {})
before_added = str(merged.get("added_services") or "")
found = harvest_added_services(text)
labeled = _labeled_added_value(text)
# 销售把「增值服务:」留空:这一栏就是空的。
@@ -122,23 +172,81 @@ def apply_air_value_added(
# 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。
if labeled == "":
items = found
branch = "labeled_empty"
elif found:
items = _dedupe(split_added_services(previous) + found)
branch = "found_merge"
else:
items = _dedupe(
split_added_services(previous)
+ split_added_services(str(merged.get("added_services") or ""))
)
branch = "keep_previous"
if items:
merged["added_services"] = "、".join(items)
elif not str(merged.get("added_services") or "").strip():
merged.pop("added_services", None)
_keep_stated_package(text, merged)
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H2",
"location": "added_services.py:apply_air_value_added",
"message": "apply merge before/after",
"data": {
"text_preview": (text or "")[:120],
"before_added": before_added,
"found": found,
"previous": previous,
"labeled": labeled,
"branch": branch,
"after_added": str(merged.get("added_services") or ""),
"pack": str(merged.get("包装方式") or ""),
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _path in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
):
try:
_path.parent.mkdir(parents=True, exist_ok=True)
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
# #endregion
return merged
def _canonical_events(raw: str) -> list[tuple[int, str]]:
"""按原文位置列出 12 个标准词。重叠区间只留更长的那一项。"""
def _attach_qty_suffix(raw: str, start: int, end: int, word: str) -> tuple[int, int, str]:
"""
服务词后面若有 +数字,并进同一项。
例:TC +1.5 → (起点, 终点, "TC+1.5")。没有数量则原样返回。
为何:销售口播常写「TC +1.5、托盘 +1」,只记服务名会丢数量。
"""
match = _QTY_SUFFIX_RE.match(raw or "", end)
if not match:
return start, end, word
return start, match.end(), f"{word}+{match.group(1)}"
def _canonical_events(raw: str) -> list[tuple[int, int, str]]:
"""
按原文位置列出 12 个标准词(可带 +数量)。
返回 (起点, 终点, 展示词)。重叠区间只留更长的那一项。
"""
found: list[tuple[int, int, str]] = []
package_pallets = _package_pallet_spans(raw)
for match in _CN_RE.finditer(raw):
@@ -147,21 +255,21 @@ def _canonical_events(raw: str) -> list[tuple[int, str]]:
continue
if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets):
continue
found.append((match.start(), match.end(), word))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), word))
for match in _MUST_GO_RE.finditer(raw):
found.append((match.start(), match.end(), "MUST GO"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "MUST GO"))
for match in _DG_RE.finditer(raw):
found.append((match.start(), match.end(), "DG"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "DG"))
for match in _TC_RE.finditer(raw):
found.append((match.start(), match.end(), "TC"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "TC"))
found.sort(key=lambda item: (item[0], -(item[1] - item[0])))
accepted: list[tuple[int, int]] = []
events: list[tuple[int, str]] = []
events: list[tuple[int, int, str]] = []
for start, end, word in found:
if any(not (end <= left or start >= right) for left, right in accepted):
continue
accepted.append((start, end))
events.append((start, word))
events.append((start, end, word))
return events
@@ -171,31 +279,19 @@ def _outside_list_text(piece: str) -> str:
「托盘」本身会被标准词扫到,这里不重复收。
「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。
带 +数量 的标准词整段擦掉,避免残留「+1.5」再当成名单外服务。
"""
spans = _canonical_events(piece)
if not spans:
return piece.strip()
chars = list(piece)
for start, word in spans:
# _canonical_events 只有起点。用标准词长度擦掉;MUST GO 用原文长度。
end = start + _span_len(piece, start, word)
for start, end, _word in spans:
for index in range(start, min(end, len(chars))):
chars[index] = " "
rest = _FILLER.sub("", "".join(chars))
return rest.strip()
def _span_len(piece: str, start: int, word: str) -> int:
"""标准词在原文里占几位。MUST GO 中间空格可有可无。"""
if word == "MUST GO":
match = _MUST_GO_RE.match(piece, start)
if match:
return match.end() - match.start()
if word in {"DG", "TC"}:
return 2
return len(word)
def _labeled_added_value(text: str) -> str | None:
"""
原话里有没有单独的「增值服务」栏。
@@ -432,6 +432,8 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any]
base["valid_until"] = src.get("validUntil")
base["selected_air_option"] = src
base["route_line"] = route_line(src, {})
# 选定后不再是待选舱快照。
base.pop("pending_cabin_pick", None)
return base
+21 -5
View File
@@ -76,7 +76,8 @@ LAND_DISPLAY = {
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
# 空运群只补含电/含磁,摘要不要把海运协同五项搬过去。
# 空运群只补含电/含磁(及报价日期),摘要不要把海运协同五项搬过去。
# 货好时间、贸易条款、客户名称不在群里补(产品口径)。
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
SEA_COLLAB_DISPLAY = {
@@ -222,6 +223,10 @@ _ALIASES = {
"海关编码": "HS编码",
"客户名称": "客户名称",
"货值": "货值",
# 模板/口语常写「是否带电」,合同内部键仍是是否含电。
"是否带电": "是否含电",
"是否含电": "是否含电",
"是否含磁": "是否含磁",
"是否为危险品": "是否为危险品",
"通关口岸": "通关口岸",
"通关口岸(非必填)": "通关口岸",
@@ -253,11 +258,15 @@ def shanghai_today() -> str:
return datetime.now(_SHANGHAI).date().isoformat()
# 销售卡片/摘要占位横杠:等于没写,禁止落进 facts 再当起运地出站(运行证据 WO202610150086)。
_PLACEHOLDER_DASHES = frozenset({"-", "—", "-"})
def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
"""
把展示名/别名折成合同内部键,去掉首尾空白。
空值丢弃;不解释语义。
空值与摘要占位「-」丢弃;不解释语义。
"""
out: dict[str, str] = {}
for raw_key, raw_val in (facts or {}).items():
@@ -268,7 +277,7 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
if raw_val is None:
continue
val = str(raw_val).strip()
if not val:
if not val or val in _PLACEHOLDER_DASHES:
continue
out[key] = val
_fold_land_vehicle_qty(out)
@@ -527,9 +536,16 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
「(如:随时可提)」这种补问示例行不算销售说了货好时间。
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
「是否带电:否」里的「带电」不能当成肯定(运行证据 session 717349)。
"""
raw = _without_clarify_examples(text or "")
out: dict[str, str] = {}
# 先剥掉「是否带电/含电/含磁:是|否」标签行,再口语匹配,避免标签里的「带电」误判成是。
oral_src = re.sub(
r"是否(?:带电|含电|含磁|含油)\s*[::]\s*[是否]",
" ",
raw,
)
yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")),
@@ -541,9 +557,9 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
),
)
for key, no_words, yes_words in yes_no:
if any(w in raw for w in no_words):
if any(w in oral_src for w in no_words):
out[key] = "否"
elif any(w in raw for w in yes_words):
elif any(w in oral_src for w in yes_words):
out[key] = "是"
ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好")
for word in ready:
@@ -202,15 +202,27 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
包装只看销售是否说了,散货等原文原样保留。
"""
src = dict(facts or {})
# 摘要占位「-」当空:不能拿去 resolve 机场,否则 TMS 回 1003 起飞机场无法匹配。
_dash = frozenset({"-", "—", "-"})
origin = str(src.get("起运港") or src.get("起运地") or "").strip()
if origin in _dash:
origin = ""
dest = str(src.get("目的港") or "").strip()
if dest in _dash:
dest = ""
cargo = str(src.get("品名") or "").strip()
if cargo in _dash:
cargo = ""
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = volume_cbm_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
if pack_raw in _dash:
pack_raw = ""
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()
if quote_date in _dash:
quote_date = ""
# 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL
origin_code = resolve_airport_code(origin)
dest_code = resolve_airport_code(dest)
@@ -240,6 +252,10 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
ok = not missing
out_facts = dict(src)
# 横杠占位从出站 facts 清掉,避免建单把「-」写入起运港列。
for _k in ("起运港", "起运地"):
if str(out_facts.get(_k) or "").strip() in _dash:
out_facts.pop(_k, None)
if origin:
out_facts["起运港"] = origin
if origin_code:
@@ -247,6 +263,8 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
else:
out_facts.pop("originCode", None)
else:
out_facts.pop("起运港", None)
out_facts.pop("起运地", None)
out_facts.pop("originCode", None)
if dest:
out_facts["目的港"] = dest