空运未填报价日期时不默认当天,查出舱位后由销售点选再写回日期。
包装类型里的托盘不再被抄进增值服务。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
空运增值服务。
|
||||
|
||||
本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。
|
||||
12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。
|
||||
包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。
|
||||
「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。
|
||||
不挡查价。不改六态。海运、陆运不走这里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from agent.schema.field_validate import _PACK_WORDS, _without_clarify_examples
|
||||
|
||||
# 长词在前,避免「卡转低消」再拆出「卡转」。
|
||||
_CN_TERMS = (
|
||||
"卡转低消",
|
||||
"前置仓",
|
||||
"托盘",
|
||||
"带电",
|
||||
"卡转",
|
||||
"备装",
|
||||
"分泡",
|
||||
"温控",
|
||||
"快件",
|
||||
)
|
||||
_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])")
|
||||
# 冒号后只吃同一行。\s 会跨行,空着的增值服务会把下一行「报价日期(非必填):」吞进来。
|
||||
_LABEL_RE = re.compile(
|
||||
r"增值服务(?:(非必填)|\(非必填\))?[^\S\n]*[::][^\S\n]*([^\n]*)"
|
||||
)
|
||||
# 包装类型/包装方式这一栏里的托盘只算包装。确认卡上增值服务留空时不能再抄一份过去。
|
||||
_PACKAGE_PALLET_RE = re.compile(
|
||||
r"包装(?:类型|方式)?(?:((?:非)?必填)|\((?:非)?必填\))?[^\S\n]*[::][^\S\n]*托盘"
|
||||
)
|
||||
_SPLIT_RE = re.compile(r"[、,,]")
|
||||
_NEG_PREFIX = frozenset("不无非")
|
||||
_FILLER = re.compile(r"[、,,\s和及与::]+")
|
||||
|
||||
|
||||
def split_added_services(raw: str) -> list[str]:
|
||||
"""
|
||||
把一栏增值服务拆成多项。
|
||||
|
||||
顿号、中文逗号、英文逗号都是分隔。空格不拆,MUST GO 仍是一项。
|
||||
副作用:无。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for part in _SPLIT_RE.split(text):
|
||||
token = part.strip().strip("。;; ")
|
||||
key = token.casefold()
|
||||
if not token or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(token)
|
||||
return out
|
||||
|
||||
|
||||
def join_added_services(items: list[str]) -> str:
|
||||
"""多项用顿号连成销售看到的一行。空则空串。"""
|
||||
return "、".join(split_added_services("、".join(items)))
|
||||
|
||||
|
||||
def harvest_added_services(text: str) -> list[str]:
|
||||
"""
|
||||
从当前原话按出现顺序抽出增值服务。
|
||||
|
||||
补问示例行「(如:托盘、…)」整行不看,避免清单被当成已填。
|
||||
「不带电」不算带电;「卡转低消」不算再加一项卡转。
|
||||
副作用:无。
|
||||
"""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
events: list[tuple[int, str]] = []
|
||||
# 包装栏里的托盘已在 _canonical_events 排除,这里只收正文里真正的服务词。
|
||||
events.extend(_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("(如"):
|
||||
continue
|
||||
base = match.start(1)
|
||||
cursor = 0
|
||||
for part in _SPLIT_RE.split(value):
|
||||
piece = part.strip().strip("。;; ")
|
||||
if not piece:
|
||||
continue
|
||||
at = value.find(part, cursor)
|
||||
if at < 0:
|
||||
at = cursor
|
||||
cursor = at + len(part)
|
||||
extra = _outside_list_text(piece)
|
||||
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])
|
||||
|
||||
|
||||
def apply_air_value_added(
|
||||
text: str,
|
||||
facts: dict[str, str] | None,
|
||||
previous: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
把本句增值服务并进事实,并避免托盘盖住已经说出的其他包装。
|
||||
|
||||
previous 是上一句已经记下的 added_services。本句没提服务时保留旧值。
|
||||
只应在空运调用。副作用:返回新字典,不改入参。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
found = harvest_added_services(text)
|
||||
labeled = _labeled_added_value(text)
|
||||
# 销售把「增值服务:」留空:这一栏就是空的。
|
||||
# 不沿用上一句,也不把包装类型里的托盘、下一行报价日期算进来。
|
||||
# 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。
|
||||
if labeled == "":
|
||||
items = found
|
||||
elif found:
|
||||
items = _dedupe(split_added_services(previous) + found)
|
||||
else:
|
||||
items = _dedupe(
|
||||
split_added_services(previous)
|
||||
+ split_added_services(str(merged.get("added_services") or ""))
|
||||
)
|
||||
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)
|
||||
return merged
|
||||
|
||||
|
||||
def _canonical_events(raw: str) -> list[tuple[int, str]]:
|
||||
"""按原文位置列出 12 个标准词。重叠区间只留更长的那一项。"""
|
||||
found: list[tuple[int, int, str]] = []
|
||||
package_pallets = _package_pallet_spans(raw)
|
||||
for match in _CN_RE.finditer(raw):
|
||||
word = match.group(0)
|
||||
if word == "带电" and match.start() > 0 and raw[match.start() - 1] in _NEG_PREFIX:
|
||||
continue
|
||||
if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets):
|
||||
continue
|
||||
found.append((match.start(), match.end(), word))
|
||||
for match in _MUST_GO_RE.finditer(raw):
|
||||
found.append((match.start(), match.end(), "MUST GO"))
|
||||
for match in _DG_RE.finditer(raw):
|
||||
found.append((match.start(), match.end(), "DG"))
|
||||
for match in _TC_RE.finditer(raw):
|
||||
found.append((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]] = []
|
||||
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))
|
||||
return events
|
||||
|
||||
|
||||
def _outside_list_text(piece: str) -> str:
|
||||
"""
|
||||
增值服务标签里、12 个词没有覆盖到的原话。
|
||||
|
||||
「托盘」本身会被标准词扫到,这里不重复收。
|
||||
「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。
|
||||
"""
|
||||
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 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:
|
||||
"""
|
||||
原话里有没有单独的「增值服务」栏。
|
||||
|
||||
没写这一栏返回 None。写了但冒号后同一行为空,返回空串。
|
||||
只看同一行,避免空栏把下一行报价日期收进来。
|
||||
"""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
seen = False
|
||||
value = ""
|
||||
for match in _LABEL_RE.finditer(raw):
|
||||
seen = True
|
||||
piece = (match.group(1) or "").strip().strip("。;; ")
|
||||
if not piece or piece.startswith("(如") or piece.startswith("(如"):
|
||||
continue
|
||||
value = piece
|
||||
if not seen:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _package_pallet_spans(raw: str) -> list[tuple[int, int]]:
|
||||
"""「包装类型:托盘」里那个托盘的起止。只算包装,不算增值服务。"""
|
||||
spans: list[tuple[int, int]] = []
|
||||
for match in _PACKAGE_PALLET_RE.finditer(raw or ""):
|
||||
end = match.end()
|
||||
spans.append((end - len("托盘"), end))
|
||||
return spans
|
||||
|
||||
|
||||
def _span_inside(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
|
||||
"""当前词是否落在给定区间里。"""
|
||||
return any(start >= left and end <= right for left, right in spans)
|
||||
|
||||
|
||||
def _keep_stated_package(text: str, facts: dict[str, str]) -> None:
|
||||
"""
|
||||
同一句里还有纸箱、木箱、卡板、散货时,包装类型保持那个词。
|
||||
|
||||
包装栏里的托盘不进增值服务,只在没另说包装时由机读记成包装类型。
|
||||
"""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
if "托盘" not in raw:
|
||||
return
|
||||
last = ""
|
||||
last_pos = -1
|
||||
for word in _PACK_WORDS:
|
||||
if word == "托盘":
|
||||
continue
|
||||
pos = raw.rfind(word)
|
||||
if pos > last_pos:
|
||||
last_pos = pos
|
||||
last = word
|
||||
if last:
|
||||
facts["包装方式"] = last
|
||||
|
||||
|
||||
def _dedupe(items: list[str]) -> list[str]:
|
||||
"""按第一次出现保留,英文忽略大小写。"""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
token = (item or "").strip()
|
||||
key = token.casefold()
|
||||
if not token or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(token)
|
||||
return out
|
||||
@@ -255,10 +255,131 @@ def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = N
|
||||
return text
|
||||
|
||||
|
||||
# TMS 舱位「班期」可能用的键。页面只展示第一个非空值,例如 D234567,不翻译。
|
||||
_SCHEDULE_KEYS = (
|
||||
"班期",
|
||||
"schedule",
|
||||
"flightSchedule",
|
||||
"flight_schedule",
|
||||
"frequency",
|
||||
"weekFrequency",
|
||||
"scheduleDays",
|
||||
"operatingDays",
|
||||
"flightFrequency",
|
||||
"departureSchedule",
|
||||
"scheduleCode",
|
||||
"flightPeriod",
|
||||
"frequencyCode",
|
||||
)
|
||||
|
||||
|
||||
def option_flight_date(option: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
舱位上的航班日期。优先 flightDate,其次从起飞时间取日期。
|
||||
|
||||
没有则空串。不编当天。
|
||||
"""
|
||||
src = dict(option or {})
|
||||
for key in ("flightDate", "flight_date", "departureDate", "departure_date"):
|
||||
text = str(src.get(key) or "").strip()
|
||||
if text:
|
||||
return _date_head(text)
|
||||
return _date_head(str(src.get("departureTime") or src.get("departure_time") or ""))
|
||||
|
||||
|
||||
def _date_head(raw: str) -> str:
|
||||
"""yyyy-MM-dd 开头就取日期,否则原样。空串保持空。"""
|
||||
text = (raw or "").strip()
|
||||
if len(text) >= 10 and text[4] == "-" and text[7] == "-":
|
||||
return text[:10]
|
||||
return text
|
||||
|
||||
|
||||
def option_schedule(option: dict[str, Any] | None) -> str:
|
||||
"""班期按 TMS 原样,例如 D234567。没有就空串,不编。"""
|
||||
src = dict(option or {})
|
||||
for key in _SCHEDULE_KEYS:
|
||||
text = str(src.get(key) or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def option_airline(option: dict[str, Any] | None) -> str:
|
||||
"""航司:有中文名用中文名,否则用代码。没有就空串。"""
|
||||
src = dict(option or {})
|
||||
for key in ("airlineName", "airline", "airlineCode", "carrier"):
|
||||
text = str(src.get(key) or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def option_endpoint_name(
|
||||
option: dict[str, Any] | None,
|
||||
side: str,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
舱位起运地或目的地。三字码能对上就用中文,对不上展示代码。
|
||||
|
||||
side 取 origin / destination。不编城市。
|
||||
"""
|
||||
src = dict(option or {})
|
||||
facts = facts or {}
|
||||
if side == "destination":
|
||||
code = str(src.get("destinationAirportCode") or src.get("destination") or "")
|
||||
fallback = str(facts.get("目的港") or facts.get("目的地") or "")
|
||||
else:
|
||||
code = str(src.get("originAirportCode") or src.get("origin") or "")
|
||||
fallback = str(facts.get("起运港") or facts.get("起运地") or "")
|
||||
return _place_name(code, fallback)
|
||||
|
||||
|
||||
def _price_sort_value(option: dict[str, Any]) -> float:
|
||||
"""总价排序用。解析不出的排到同一天最后。"""
|
||||
raw = option.get("salePrice")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return float("inf")
|
||||
num = ""
|
||||
for ch in str(raw).replace(",", ""):
|
||||
if ch.isdigit() or ch == ".":
|
||||
num += ch
|
||||
elif num:
|
||||
break
|
||||
try:
|
||||
return float(num) if num else float("inf")
|
||||
except ValueError:
|
||||
return float("inf")
|
||||
|
||||
|
||||
def sort_cabin_options(options: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
没填报价日期时的舱位顺序:航班日期从早到晚,同一天总价从低到高。
|
||||
|
||||
没有日期的排在最后。不改入参。
|
||||
"""
|
||||
rows = [dict(item) for item in list(options or [])]
|
||||
|
||||
def _key(item: dict[str, Any]) -> tuple[str, float]:
|
||||
day = option_flight_date(item) or "9999-99-99"
|
||||
return (day, _price_sort_value(item))
|
||||
|
||||
return sorted(rows, key=_key)
|
||||
|
||||
|
||||
def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""选定线路后写回起运港(及能对上的三字码)。改选覆盖。"""
|
||||
"""
|
||||
选定舱位后写回起运港(及能对上的三字码)。改选覆盖。
|
||||
|
||||
工单还没有报价日期时,把这条的航班日期写回去,供报价单和锁舱使用。
|
||||
销售已经说了日期则不覆盖。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
src = dict(option or {})
|
||||
flight_date = option_flight_date(src)
|
||||
if flight_date and not str(merged.get("报价日期") or "").strip():
|
||||
merged["报价日期"] = flight_date
|
||||
code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper()
|
||||
name = _place_name(code)
|
||||
if name:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。
|
||||
|
||||
本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。
|
||||
本文件职责:判断首次查价缺项。海运、陆运报价日期未说则填当天,不进入补问。
|
||||
空运报价日期非必填:没说不填当天、不进补问,确认后不带日期查价。
|
||||
口语已写 TMS 单位(件/KGS/CBM)时,空着的件数/毛重/体积可机读补上。
|
||||
禁止:正则猜港口/箱型;禁止用封闭枚举拒绝用户原文;禁止改六态。
|
||||
"""
|
||||
@@ -37,6 +38,7 @@ AIR_OPTIONAL_FIELDS = (
|
||||
("提货地址", "提货地址"),
|
||||
("收货地址", "收货地址"),
|
||||
("贸易条款", "贸易条款"),
|
||||
("added_services", "增值服务"),
|
||||
)
|
||||
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
|
||||
AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS})
|
||||
@@ -236,6 +238,9 @@ _ALIASES = {
|
||||
"贸易条款(非必填)": "贸易条款",
|
||||
"起运港(非必填)": "起运港",
|
||||
"起运地(非必填)": "起运港",
|
||||
"增值服务": "added_services",
|
||||
"增值服务(非必填)": "added_services",
|
||||
"addedServices": "added_services",
|
||||
}
|
||||
for _quote_alias in QUOTE_DATE_SYNONYMS:
|
||||
_ALIASES[_quote_alias] = "报价日期"
|
||||
@@ -645,7 +650,11 @@ def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str
|
||||
|
||||
|
||||
def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
|
||||
"""销售未提供报价日期时填当天;已有则不覆盖。"""
|
||||
"""
|
||||
海运、陆运:销售未提供报价日期时填上海当天;已有则不覆盖。
|
||||
|
||||
空运不要走这里。空运没说日期必须保持空白,不能填成当天。
|
||||
"""
|
||||
merged = dict(facts)
|
||||
if not merged.get("报价日期"):
|
||||
merged["报价日期"] = shanghai_today()
|
||||
@@ -730,13 +739,17 @@ def validate_required_fields(
|
||||
"""
|
||||
首次查价完整性。
|
||||
|
||||
返回:ok / implemented / missing(内部键)/ missing_display / facts(含默认报价日期)/ errors。
|
||||
报价日期不进 missing。未识别业务线时 missing 为空、need_transport_mode=True。
|
||||
返回:ok / implemented / missing(内部键)/ missing_display / facts / errors。
|
||||
报价日期不进 missing。海运、陆运没说则 facts 里带上当天;空运没说则保持空白。
|
||||
未识别业务线时 missing 为空、need_transport_mode=True,此时不填日期。
|
||||
"""
|
||||
_ = require_formal
|
||||
contract = require_formal_contract("inquiry-required-fields-v1")
|
||||
normalized = peel_trailing_place_clauses(apply_quote_date_default(normalize_facts(facts)))
|
||||
normalized = peel_trailing_place_clauses(normalize_facts(facts))
|
||||
line = (business_line or "").strip().upper()
|
||||
# 只有海运、陆运把空日期补成当天。空运留给销售稍后在舱位里选定航班日期。
|
||||
if line in {"SEA", "LAND"}:
|
||||
normalized = apply_quote_date_default(normalized)
|
||||
if line == "SEA":
|
||||
normalized = project_sea_cargo_qty(normalized)
|
||||
from agent.schema.sea_options import canonicalize_sea_class
|
||||
|
||||
@@ -236,9 +236,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not pack_raw:
|
||||
missing.append("包装方式")
|
||||
missing_display.append("包装类型")
|
||||
if not quote_date:
|
||||
missing.append("报价日期")
|
||||
missing_display.append("报价日期")
|
||||
# 报价日期非必填:空着仍可查价,出站不带日期,不能补成当天。
|
||||
|
||||
ok = not missing
|
||||
out_facts = dict(src)
|
||||
|
||||
Reference in New Issue
Block a user