机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
@@ -105,50 +105,6 @@ def harvest_added_services(text: str) -> list[str]:
events.append((base + at + max(piece.find(extra), 0), extra))
events.sort(key=lambda item: item[0])
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
@@ -187,44 +143,6 @@ def apply_air_value_added(
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
+258 -12
View File
@@ -240,23 +240,221 @@ def _rows_from_list(raw: Any) -> list[tuple[str, str]]:
return out
def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]:
"""
该线路 TMS 回了哪些费用就列哪些。
def _fee_item_dicts(raw: Any) -> list[dict[str, Any]]:
"""TMS 费用数组收成字典;字符串 JSON 也认。无名称的跳过。"""
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
name = str(
item.get("chargeName") or item.get("feeName") or item.get("name") or item.get("label") or ""
).strip()
if not name:
continue
row = dict(item)
row["name"] = name
out.append(row)
return out
只读本条 departureCharges / feeItems。多条线路时,整单 fee_lines 是最低价方案,
禁止套到没回费用的线路。仅一条线路时,整单 fee_lines 才算这条的明细。
def _top_fee_items_for_option(
quote: dict[str, Any] | None,
option: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""
舱位缺 feeItems 时补整单顶层(OMS 空运表价常把空运费放在 data.feeItems)。
顶层带 airOptionNo 时优先本舱位;对不上编号时不能退成空列表(否则只剩增点/拆板)。
与主账 InquiryTmsQueryService.extractAirDepartureCharges 口径一致。
"""
items = _fee_item_dicts((quote or {}).get("feeItems") or (quote or {}).get("fee_items"))
if not items:
return []
opt_no = str(
(option or {}).get("optionNo") or (option or {}).get("option_no") or ""
).strip()
tagged = [
row
for row in items
if str(row.get("airOptionNo") or row.get("air_option_no") or "").strip()
]
if opt_no and tagged:
matched = [
row
for row in items
if str(row.get("airOptionNo") or row.get("air_option_no") or "").strip() == opt_no
]
if matched:
return matched
return items
def _has_air_freight_name(rows: list[dict[str, Any]]) -> bool:
"""明细里是否已有空运段运费名。"""
for row in rows:
name = str(row.get("name") or "").strip()
if name == "空运费" or name == "航空运费" or (name.endswith("运费") and "空运" in name):
return True
return False
def _only_origin_surcharges(rows: list[dict[str, Any]]) -> bool:
"""
是否只剩起运附加费(增点/拆板等),没有运输费类分项。
有运输费/提送货费时不注入总价空运费,避免和 ZUH 分项叠两套。
"""
if not rows:
return False
for row in rows:
name = str(row.get("name") or "").strip()
if not name:
continue
if name in {"运输费", "提送货费", "人工费", "空运费", "航空运费"}:
return False
if "运费" in name and "增点" not in name and "拆板" not in name:
return False
return True
def _air_freight_from_quote_fee_lines(
quote: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""主账 fee_lines 里已有的空运费行(带单价/单位更好)。"""
rows = _fee_item_dicts(
(quote or {}).get("fee_lines") or (quote or {}).get("fee_rows")
)
return [row for row in rows if str(row.get("name") or "").strip() in {"空运费", "航空运费"}]
def _inject_air_freight_from_option(
option: dict[str, Any] | None,
quote: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""
舱位/顶层都没有空运费时,用 salePrice 或 unitPrice×计费重补一行。
对齐主账 injectAirFreightFeeLineIfMissing。无可用金额则不补。
"""
src = dict(option or {})
rows = _rows_from_list(src.get("departureCharges") or src.get("departure_charges"))
rows.extend(_rows_from_list(src.get("feeItems") or src.get("fee_items")))
q = dict(quote or {})
unit_price = src.get("unitPrice")
if unit_price in (None, "", "-"):
unit_price = src.get("unit_price") or src.get("pricePerKg") or src.get("freightRate")
sale = src.get("salePrice")
if sale in (None, "", "-"):
sale = src.get("sale_price") or q.get("total") or q.get("salePrice")
cw = src.get("chargeableWeightKg")
if cw in (None, "", "-"):
cw = src.get("chargeable_weight_kg") or q.get("chargeableWeightKg")
amount = ""
price = str(unit_price or "").strip()
cw_text = str(cw or "").strip()
if price and cw_text:
try:
amount = str(
(float(str(price).replace(",", "")) * float(cw_text.replace(",", "")))
)
# 去掉多余尾零:3334.0
amount = f"{float(amount):.10g}"
except ValueError:
amount = ""
if not amount:
amount = str(sale or "").strip()
if not amount or amount == "-":
return None
if (not price or price == "-") and cw_text:
try:
price = f"{(float(str(amount).replace(',', '')) / float(cw_text.replace(',', ''))):.10g}"
except ValueError:
price = amount
ccy = str(src.get("currency") or q.get("currency") or "").strip()
row: dict[str, Any] = {
"name": "空运费",
"amount": amount,
"project": "空运段",
}
if ccy:
row["currency"] = ccy
if price and price != "-":
row["unit_price"] = price
row["unitPrice"] = price
if cw_text:
row["quantity"] = cw_text
row["unit"] = "KG"
return row
def option_fee_dicts(
option: dict[str, Any] | None,
quote: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""
该线路费用明细(保留单价/单位/数量),供群摘要与投影写回 fee_lines。
顺序与主账一致:空运段 feeItems → 起运港 departureCharges → 目的地。
舱位 feeItems 为空时补整单顶层,避免只剩增点费/拆板费丢掉空运费(WO202610150047)。
"""
src = dict(option or {})
q = dict(quote or {})
cabin = _fee_item_dicts(src.get("feeItems") or src.get("fee_items"))
if not cabin:
cabin = _top_fee_items_for_option(q, src)
dep = _fee_item_dicts(
src.get("departureCharges") or src.get("departure_charges") or src.get("charges")
)
dest = _fee_item_dicts(
src.get("destinationCharges")
or src.get("destination_charges")
or src.get("arrivalCharges")
)
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for bucket in (cabin, dep, dest):
for row in bucket:
key = str(row.get("name") or "").strip().lower()
if not key or key in seen:
continue
seen.add(key)
rows.append(row)
# 仍无空运费:先取主账 fee_lines,再按舱位总价注入(OMS 仅增点/拆板时)
if rows and not _has_air_freight_name(rows):
for row in _air_freight_from_quote_fee_lines(q):
key = str(row.get("name") or "").strip().lower()
if key and key not in seen:
seen.add(key)
rows.insert(0, row)
if not _has_air_freight_name(rows) and _only_origin_surcharges(rows):
extra = _inject_air_freight_from_option(src, q)
if extra:
rows.insert(0, extra)
if rows:
return rows
q = dict(quote or {})
options = parse_air_options(q)
if len(options) > 1:
return []
return _rows_from_list(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems"))
return _fee_item_dicts(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems"))
def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]:
"""
该线路 TMS 回了哪些费用就列哪些(名称 + 展示金额)。
明细字典见 option_fee_dicts:舱位缺空运段时会补整单顶层 feeItems。
"""
out: list[tuple[str, str]] = []
for row in option_fee_dicts(option, quote):
pair = _fee_row(row)
if pair:
out.append(pair)
return out
def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> str:
@@ -393,10 +591,13 @@ def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] |
工单还没有报价日期时,把这条的航班日期写回去,供报价单和锁舱使用。
销售已经说了日期则不覆盖。
"""
from agent.schema.field_validate import is_blank_quote_date
merged = dict(facts or {})
src = dict(option or {})
flight_date = option_flight_date(src)
if flight_date and not str(merged.get("报价日期") or "").strip():
# 占位文案「(非必填):」当没填,必须让航班日期盖上去。
if flight_date and is_blank_quote_date(merged.get("报价日期")):
merged["报价日期"] = flight_date
code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper()
name = _place_name(code)
@@ -417,7 +618,33 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any]
"""把选中线路投影成价格卡用的 quote。"""
base = dict(quote or {})
src = dict(option or {})
lines = [{"name": n, "amount": a} for n, a in option_fee_lines(src, base)]
# 保留单价/单位/数量,避免后面把「空运费=总价」误判成重复兜底删掉。
lines: list[dict[str, Any]] = []
for row in option_fee_dicts(src, base):
pair = _fee_row(row)
if not pair:
continue
name, amount = pair
line = {
"name": name,
"amount": amount,
"currency": str(row.get("currency") or row.get("currencyCode") or "").strip(),
}
unit = str(row.get("unit") or row.get("unitName") or row.get("chargeUnit") or "").strip()
if unit:
line["unit"] = unit
unit_price = row.get("unitPrice")
if unit_price in (None, ""):
unit_price = row.get("unit_price")
if unit_price not in (None, ""):
line["unit_price"] = unit_price
line["unitPrice"] = unit_price
qty = row.get("quantity")
if qty in (None, ""):
qty = row.get("chargeQuantity") or row.get("charge_quantity")
if qty not in (None, ""):
line["quantity"] = qty
lines.append(line)
# 必须覆盖整单 fee_lines:空列表也要写回去,否则会残留别的线路空运费。
base["fee_lines"] = lines
if not lines:
@@ -515,6 +742,24 @@ def _same_amount(left: Any, right: Any) -> bool:
return a == b
def _rated_air_freight_line(item: dict[str, Any] | None) -> bool:
"""
是否为带计价单位的真实空运费行(单价×计费重),而非「空运费=总价」快照兜底。
有 KG 单位、或有单价/数量,都按真实费率行保留。
"""
src = dict(item or {})
unit = str(src.get("unit") or src.get("unitName") or src.get("chargeUnit") or "").strip().upper()
unit = unit.replace("/", "").replace("每", "").replace(" ", "")
if unit in {"KG", "KGS", "公斤", "千克"}:
return True
if src.get("unitPrice") not in (None, "", "-") or src.get("unit_price") not in (None, "", "-"):
return True
if src.get("quantity") not in (None, "", "-") or src.get("chargeQuantity") not in (None, "", "-"):
return True
return False
def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]:
"""
已经有其它费用分项时,去掉「空运费=总价」这一行兜底。
@@ -542,7 +787,8 @@ def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]
continue
name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
amount = item.get("amount") or item.get("value")
if name == "空运费" and _same_amount(amount, total):
# OMS 空运费金额常等于顶层 salePrice,但带 KG 单价/数量,不是「总价兜底」假行。
if name == "空运费" and _same_amount(amount, total) and not _rated_air_freight_line(item):
alias.append(item)
continue
others.append(item)
+87 -2
View File
@@ -279,6 +279,15 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
val = str(raw_val).strip()
if not val or val in _PLACEHOLDER_DASHES:
continue
# 补问提示「请复制粘贴…」等不是日期,入库前丢掉,避免当成已填走出「线路」。
if key == "报价日期" and is_blank_quote_date(val):
continue
# 「请确认并补充以下信息」等标题不是品名(空栏后没换行会粘上)。
if key in {"品名", "货物品名"}:
from agent.llm.extract_text import is_clarify_ui_prompt
if is_clarify_ui_prompt(val):
continue
out[key] = val
_fold_land_vehicle_qty(out)
return out
@@ -319,6 +328,29 @@ def _harvest_packaging(raw: str) -> str:
return last
def is_blank_quote_date(raw: str | None) -> bool:
"""
报价日期是否等于「没填」。
核对卡空行会写成「报价日期(非必填):」。若把「(非必填):」收成字段值,
会挡住选舱写回航班日期(WO202610160033 运行证据)。横杠、装饰词都当空。
补问提示「请复制粘贴…」曾被跨行收成日期(核对卡挤行),一律当空。
"""
text = str(raw or "").strip()
if not text:
return True
if text in {"-", "—", "-", "/", "无", "没有", "暂无"}:
return True
# 补问 UI 文案、下一栏编号,不是日期。
if "请复制粘贴" in text or "告知我以下字段" in text:
return True
if re.match(r"^\d+[\.、.]", text):
return True
cleaned = re.sub(r"[((]\s*非必填\s*[))]", "", text)
cleaned = cleaned.strip(" \t::,,。;;、")
return not cleaned
def _quote_date_labeled_value(raw: str) -> str:
"""
原话里「报价日期」或同义词后面的值。
@@ -327,20 +359,25 @@ def _quote_date_labeled_value(raw: str) -> str:
能写成 yyyy-MM-dd 就规范;「明天」这类留原文。
没有这些标签则空串,交给句子里的裸日期。
同一句出现多次,用最后一次,避免前面的货好时间把日期抢走。
标签后可带「(非必填)」;值为空或只剩装饰词时不当作已填。
只认冒号后同一行:空行后面的「请复制粘贴…」不能跨行收成日期。
"""
text = raw or ""
labels = ("报价日期",) + QUOTE_DATE_SYNONYMS
best_at = -1
best_val = ""
for label in labels:
# 允许「报价日期(非必填):」;禁止把「(非必填):」收成日期值。
# [^\S\n] 只吃同行空白,避免 \s 跨行吞粘贴提示。
pattern = re.compile(
rf"(?<![\u4e00-\u9fffA-Za-z0-9]){re.escape(label)}\s*[::]?\s*(\S+)"
rf"(?<![\u4e00-\u9fffA-Za-z0-9]){re.escape(label)}"
rf"(?:[^\S\n]*[((]\s*非必填\s*[))])?[^\S\n]*[::]?[^\S\n]*([^\s\n]*)"
)
for hit in pattern.finditer(text):
if hit.start() >= best_at:
best_at = hit.start()
best_val = hit.group(1).strip().strip(",,。;;、")
if not best_val:
if is_blank_quote_date(best_val):
return ""
return _harvest_quote_date(best_val) or _excel_serial_date(best_val) or best_val
@@ -603,6 +640,16 @@ def harvest_spoken_trade_term(text: str) -> dict[str, str]:
_CONTAINER_SPEC = re.compile(
r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)"
)
# 陆运车型口语:45HQ(13.5厢车)、9米6/1、13.5厢车、3T。不收 9.11 日期、20件。
_RE_LAND_VEHICLE = re.compile(
r"(?i)(?:"
r"(?:20|40|45)\s*(?:HQ|GP|HC|NOR|RH|高柜|平柜)"
r"(?:\s*[((][^))]{1,24}[))])?"
r"|(?:\d+(?:\.\d+)?)\s*米\s*[0-9六]?(?:\s*[//xX×*]\s*\d+)?"
r"|(?:\d+(?:\.\d+)?)\s*厢车"
r"|(?<![\d.])(\d+(?:\.\d+)?)\s*[Tt](?![A-Za-z])"
r")"
)
def looks_like_container_spec(text: str) -> bool:
@@ -610,6 +657,42 @@ def looks_like_container_spec(text: str) -> bool:
return bool(_CONTAINER_SPEC.search(text or ""))
def promote_land_vehicle_from_container(facts: dict[str, str] | None) -> dict[str, str]:
"""
陆运时把误落入「箱型箱量」的车型改写到「车型/数量」。
DeepSeek B 常把 45HQ/厢车当成海运箱型;陆运必填是车型/数量。
已有车型则不覆盖。返回新 dict,不改入参。
"""
merged = dict(facts or {})
vehicle = (merged.get("车型/数量") or merged.get("车型数量") or "").strip()
container = (merged.get("箱型箱量") or "").strip()
if not vehicle and container:
merged["车型/数量"] = container
return merged
def harvest_oral_land_vehicle(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
从陆运原话机读车型/数量。
已有「车型/数量」不覆盖。命中 45HQ(13.5厢车)、9米6、厢车、3T 等。
只填空字段,不猜港口。返回新 dict。
"""
merged = dict(facts or {})
if (merged.get("车型/数量") or "").strip():
return merged
raw = _without_clarify_examples(text or "")
hit = _RE_LAND_VEHICLE.search(raw)
if not hit:
return merged
token = hit.group(0).strip()
if not token:
return merged
merged["车型/数量"] = token
return merged
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
"""
海运货物数量投影。
@@ -655,6 +738,8 @@ def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str
merged.pop(key, None)
return merged
if line == "LAND":
# 先把误入箱型箱量的车型改写过来,再删海运外键。
merged = promote_land_vehicle_from_container(merged)
for key in _LAND_DROP_FOREIGN:
merged.pop(key, None)
return merged
@@ -97,6 +97,10 @@ _TYPE_ALIASES = (
("国内拼车", "国内运输拼车"),
("国内整车", "国内运输整车"),
("跨境集拼", "跨境集拼"),
# 口语「中越整车」等:先于短词「跨境整车」,避免漏收成国内拼车。
("中越整车", "跨境整车"),
("中缅整车", "跨境整车"),
("中老整车", "跨境整车"),
("跨境整车", "跨境整车"),
)
@@ -108,6 +112,10 @@ _ROUTE_ALIASES = (
("国内长途/零担", "国内长途/零担"),
("国内长途", "国内长途/零担"),
("国内零担", "国内长途/零担"),
# 中越/中缅/中老线路口语 → 东南亚(越南谅山等)。
("中越", "东南亚"),
("中缅", "东南亚"),
("中老", "东南亚"),
("东南亚", "东南亚"),
)
@@ -446,11 +454,16 @@ def land_cargo_name(facts: dict[str, Any] | None) -> str:
陆运货物品名。六个运输类型都要。
示例行「(如:衣服)」原样贴回不算已填。没有则空串。
补问标题「请确认并补充以下信息」不算品名。
"""
from agent.llm.extract_text import is_clarify_ui_prompt
src = facts or {}
name = str(src.get("品名") or src.get("货物品名") or "").strip()
if not name or _CARGO_PLACEHOLDER.match(name):
return ""
if is_clarify_ui_prompt(name):
return ""
return name