陆运补问把货物品名放进选项清单,并按销售原文回显已填项。

空着的运输分类不再被菜单第一项认成已填。空运报价改为货物信息加价格方案,锁舱成功回执补起运地、目的地、航司和航班日期。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 17:39:54 +08:00
co-authored by Cursor
parent cc8529f569
commit 9ffba70741
21 changed files with 1129 additions and 130 deletions
+47 -2
View File
@@ -69,6 +69,42 @@ def _ticket_flight_date(ticket: object) -> str:
).strip()
def _lock_receipt_fields(
ticket: object,
quote: dict[str, Any],
option_no: str,
airline: str,
) -> tuple[str, str, str, str]:
"""
锁舱成功回执的起运地、目的地、航司、航班日期。
优先级与主账出站一致:舱位三字码/航班日优先,没有再用工单事实。
航司用本次实际锁出去的两位码。不编造。
"""
from agent.schema.air_route_options import airport_code_for_display, option_flight_date
row = _option_row(quote, option_no) or _selected_cabin_row(quote) or {}
facts = dict(ticket_view(ticket).get("facts") or {})
origin = airport_code_for_display(
str(row.get("originAirportCode") or row.get("origin") or facts.get("originCode") or ""),
str(facts.get("起运地") or facts.get("起运港") or ""),
)
destination = airport_code_for_display(
str(
row.get("destinationAirportCode")
or row.get("destination")
or facts.get("destinationCode")
or ""
),
str(facts.get("目的地") or facts.get("目的港") or ""),
)
airline_text = (airline or "").strip().upper()
if not airline_text:
airline_text = str(row.get("airlineCode") or row.get("airline") or "").strip().upper()
flight_date = option_flight_date(row) or _ticket_flight_date(ticket)
return origin, destination, airline_text, flight_date
def is_airline(flow: Any, sender_id: str) -> bool:
"""员工管理岗位=航线·空运且启用。"""
uid = (sender_id or "").strip()
@@ -1001,6 +1037,9 @@ def apply_air_lock(
flight_no=flight_no,
instruction=instruction,
)
origin, destination, airline_label, flight_date = _lock_receipt_fields(
ticket, quote, picked, air_code
)
missing = _missing_lock_labels(picked, air_code, flight)
if missing and _quote_ready_for_lock(quote):
_remember_partial_lock(
@@ -1119,12 +1158,14 @@ def apply_air_lock(
conversation_target=chat_id,
extra={
"option_no": picked,
"airline": air_code,
"airline": airline_label or air_code,
"flight_no": flight,
"instruction": instruction,
"sender_id": sender_id,
"operator_name": name,
"flight_date": _ticket_flight_date(ticket),
"flight_date": flight_date,
"origin": origin,
"destination": destination,
},
),
ledger=flow.ledger,
@@ -1155,6 +1196,10 @@ def apply_air_lock(
option_no=picked,
exec_no=exec_no,
quote_no=quote_no,
origin=origin,
destination=destination,
airline=airline_label,
flight_date=flight_date,
),
)
from agent.policy.system_exception import clear_system_exception, ticket_is_paused
@@ -0,0 +1,362 @@
"""
空运 TMS 报价正文:私聊价格明细,以及协同群有价时的货物信息和 TMS 段。
本文件职责:只排销售和群里看得见的文字。费用名、单价、单位、备注、合计、有效期、航司、计费重、货型都取 TMS 原文。
起运地、品名、件数等仍用工单上销售已填的内容。
禁止:改按钮、改卡片、改海运陆运、没查到价时另写一套、本地编备注或货型。
"""
from __future__ import annotations
import json
from typing import Any
# 货型没有单独合同字段。TMS 若带回下列键,原样展示;都没有就「-」。
_CARGO_TYPE_KEYS = (
"cargoType",
"goodsType",
"cargoRatio",
"weightVolumeRatio",
"volumeWeightRatio",
"heavyBubbleRatio",
"bubbleRatio",
"densityRatio",
"cargoShape",
)
_REMARK_KEYS = (
"remark",
"remarks",
"note",
"specialRemarks",
"special_remarks",
"quoteRemark",
"description",
)
def air_private_quote_text(
*,
work_order_no: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
) -> str:
"""
私聊查到一条空运 TMS 报价后的完整文字。
先货物信息,再「价格方案如下」。协同说明和按钮不在这里,仍由后面的卡片发。
副作用:无。线程:调用方线程。
"""
lines = air_cargo_lines(work_order_no=work_order_no, facts=facts, quote=quote)
lines.append("")
lines.append("价格方案如下:")
lines.extend(air_price_lines(quote, facts))
return "\n".join(lines)
def air_cargo_lines(
*,
work_order_no: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
) -> list[str]:
"""
货物信息。工单字段与现在确认工单同一套单位;航司、计费重、货型只认 TMS。
报价日期没填写成「-」,不写「非必填」。增值服务有内容才多一行。
"""
from agent.policy.inquiry_copy import _with_unit
src = dict(facts or {})
origin = (src.get("起运港") or src.get("起运地") or "-").strip() or "-"
dest = (src.get("目的港") or src.get("目的地") or "-").strip() or "-"
cargo = (src.get("品名") or "-").strip() or "-"
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
quote_date = (src.get("报价日期") or "").strip()
if quote_date in {"", "-", "—", "-"}:
quote_date = "-"
lines = [
f"工单号:{(work_order_no or '').strip()}",
"运输方式:空运",
f"起运地:{origin}",
f"目的地:{dest}",
f"航司:{_airline_display(quote, src)}",
f"品名:{cargo}",
f"件数:{_with_unit(str(src.get('件数') or ''), '件')}",
f"重量(KG):{_with_unit(str(src.get('毛重') or ''), 'KGS')}",
f"体积(CBM):{_with_unit(str(src.get('体积') or ''), 'CBM')}",
f"包装类型:{pack}",
]
added = (src.get("added_services") or src.get("增值服务") or "").strip()
if added and added not in {"-", "—", "-"}:
lines.append(f"增值服务:{added}")
lines.append(f"计费重:{_chargeable_display(quote, src)}")
lines.append(f"货型:{_cargo_type_display(quote, src)}")
lines.append(f"报价日期:{quote_date}")
return lines
def air_price_lines(
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
) -> list[str]:
"""
TMS 价格段:费用行、备注、费用合计、报价有效期。
有单价且有计价单位:单价CNY11/KG。按票:CNY300/票。只有金额:CNY 100.0。
备注每次都有,TMS 没带回就「-」。不写时效。
"""
from agent.policy.inquiry_copy import money_text, tms_valid_until_display
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
lines = [_format_fee(row, header_ccy) for row in _fee_rows(src, facts)]
lines = [line for line in lines if line]
lines.append(f"备注:{_remark_display(src, facts)}")
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
lines.append(f"费用合计:{total}")
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
return lines
def _airline_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
"""航司优先代码(TH)。只有中文名时用中文名。没有就「-」。"""
option = _option(quote, facts)
blobs = [option, dict(quote or {})]
for blob in blobs:
for key in ("airlineCode", "carrierCode"):
text = str(blob.get(key) or "").strip()
if text:
return text
for blob in blobs:
raw = str(blob.get("airline") or blob.get("carrier") or "").strip()
if raw and _looks_like_code(raw):
return raw
for blob in blobs:
name = str(blob.get("airlineName") or "").strip()
if name:
return name
raw = str(blob.get("airline") or blob.get("carrier") or "").strip()
if raw:
return raw
return "-"
def _looks_like_code(raw: str) -> bool:
"""两到四位字母数字才当航司代码,避免把中文名收成代码。"""
token = raw.replace(" ", "")
return token.isascii() and token.isalnum() and 2 <= len(token) <= 4
def _chargeable_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
"""计费重。纯数字补 KG;TMS 已带单位则原样。没有就「-」。"""
option = _option(quote, facts)
for blob in (option, dict(quote or {})):
raw = blob.get("chargeableWeightKg")
if raw in (None, "", "-"):
raw = blob.get("chargeable_weight_kg") or blob.get("chargeableWeight")
text = str(raw or "").strip()
if not text or text in {"-", "—", "-"}:
continue
upper = text.upper()
if any(upper.endswith(unit) for unit in ("KG", "KGS", "公斤", "千克")):
return text
return f"{_compact_number(text)}KG"
return "-"
def _cargo_type_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
"""货型只展示 TMS 带回的原文,例如 1:212。没有就「-」。"""
option = _option(quote, facts)
for blob in (option, dict(quote or {})):
for key in _CARGO_TYPE_KEYS:
text = str(blob.get(key) or "").strip()
if text and text not in {"-", "—", "-"}:
return text
return "-"
def _remark_display(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> str:
"""报价备注。整单没有时,把费用行上不重复的备注拼起来。仍没有就「-」。"""
src = dict(quote or {})
option = _option(src, facts or {})
for blob in (src, option):
for key in _REMARK_KEYS:
text = str(blob.get(key) or "").strip()
if text and text not in {"-", "—", "-"}:
return text
notes: list[str] = []
for row in _fee_rows(src, facts):
note = str(row.get("remark") or row.get("note") or row.get("description") or "").strip()
if note and note not in notes and note not in {"-", "—", "-"}:
notes.append(note)
return ";".join(notes) if notes else "-"
def _option(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> dict[str, Any]:
"""群摘要和私聊共用:已选定的那条,或仅一条,或起运目的对得上的那条。"""
from agent.schema.air_route_options import resolve_display_air_option
found = resolve_display_air_option(quote, facts)
return dict(found or {})
def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> list[dict[str, Any]]:
"""
该条线路的原始费用。
先读舱位上的空运段、起运港、目的地。多条线路对不上时不借用整单费用。
已经有其它分项时,去掉「空运费=总价」这一行兜底。
"""
from agent.schema.air_route_options import (
drop_air_freight_total_alias,
parse_air_options,
resolve_display_air_option,
)
src = dict(quote or {})
option = resolve_display_air_option(src, facts)
rows: list[dict[str, Any]] = []
if option:
rows.extend(_named_rows(option.get("feeItems") or option.get("fee_items")))
rows.extend(
_named_rows(
option.get("departureCharges")
or option.get("departure_charges")
or option.get("charges")
)
)
rows.extend(
_named_rows(
option.get("destinationCharges")
or option.get("destination_charges")
or option.get("arrivalCharges")
)
)
rows = _dedupe(rows)
if not rows and len(parse_air_options(src)) > 1:
return []
if not rows:
rows = _named_rows(src.get("feeItems") or src.get("fee_items"))
if not rows:
rows = _named_rows(src.get("fee_lines") or src.get("fee_rows"))
if not rows:
return []
projected = drop_air_freight_total_alias({"total": src.get("total"), "fee_lines": rows})
kept = projected.get("fee_lines")
return [row for row in kept if isinstance(row, dict)] if isinstance(kept, list) else rows
def _named_rows(raw: Any) -> list[dict[str, Any]]:
"""费用数组收成字典。字符串 JSON 也认。没有名称的跳过。"""
parsed = _parse_list(raw)
out: list[dict[str, Any]] = []
for item in parsed:
row = dict(item)
name = str(
row.get("name") or row.get("chargeName") or row.get("feeName") or row.get("label") or ""
).strip()
if not name:
continue
row["name"] = name
out.append(row)
return out
def _parse_list(raw: Any) -> list[dict[str, Any]]:
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
return [dict(item) for item in raw if isinstance(item, dict)]
def _dedupe(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""同名费用保留先出现的桶,和主账投影一致。"""
seen: set[str] = set()
out: list[dict[str, Any]] = []
for row in rows:
key = str(row.get("name") or "").strip().lower()
if not key or key in seen:
continue
seen.add(key)
out.append(row)
return out
def _format_fee(row: dict[str, Any], header_ccy: str) -> str:
"""一条费用。没有可展示的金额就不出现。"""
from agent.policy.inquiry_copy import money_text
name = str(row.get("name") or "").strip()
if not name:
return ""
ccy = _currency(str(row.get("currency") or row.get("currencyCode") or header_ccy or ""))
unit = _bill_unit(str(row.get("unit") or row.get("unitName") or row.get("chargeUnit") or ""))
unit_price = _first_text(row.get("unitPrice"), row.get("unit_price"))
amount = _first_text(row.get("amount"), row.get("value"))
if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
shown = amount if "%" in amount else f"{amount}%"
return f"{name}:{shown}"
if unit and unit_price and unit != "票":
price = _compact_number(unit_price)
prefix = f"单价{ccy}{price}" if ccy else f"单价{price}"
return f"{name}:{prefix}/{unit}"
if unit == "票":
num = unit_price or amount
if num:
body = f"{ccy}{_compact_number(num)}" if ccy else _compact_number(num)
return f"{name}:{body}/票"
shown = amount or unit_price
if not shown:
return ""
return f"{name}:{money_text(shown, ccy)}"
def _bill_unit(raw: str) -> str:
"""计价单位收成 KG / 票 / CBM。空串表示 TMS 没给单位。"""
text = (raw or "").strip()
if not text:
return ""
token = text.upper().replace("/", "").replace("每", "").replace(" ", "")
if token in {"票", "SHIPMENT", "BL"} or text in {"票", "/票", "每票"}:
return "票"
if token in {"KG", "KGS", "公斤", "千克"}:
return "KG"
if token in {"CBM", "M3", "立方米", "立方"}:
return "CBM"
return text.lstrip("/")
def _currency(raw: str) -> str:
code = (raw or "").strip().upper()
if code == "RMB":
return "CNY"
return code
def _first_text(*values: Any) -> str:
for value in values:
text = str(value if value is not None else "").strip()
if text and text not in {"-", "—", "-", "None"}:
return text
return ""
def _compact_number(raw: str) -> str:
"""单价和按票金额去掉多余的尾零:11.00 写成 11。解析不了就原样。"""
text = (raw or "").strip().replace(",", "")
for prefix in ("CNY", "USD", "EUR", "HKD", "RMB"):
if text.upper().startswith(prefix):
text = text[len(prefix):].strip()
break
try:
number = float(text)
except ValueError:
return text
if number == int(number):
return str(int(number))
return f"{number:.4f}".rstrip("0").rstrip(".")
@@ -870,6 +870,7 @@ class AirTextInquiryFlow:
reply,
work_order_no=sess.work_order_no,
quote=sess.quote,
facts=sess.facts,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status=sess.status or "已报价",
@@ -1355,6 +1356,7 @@ class AirTextInquiryFlow:
reply,
work_order_no=no,
quote=quote,
facts=sess.facts,
first_or_same=first,
history_work_order_no=history_wo,
status="已报价",
@@ -1479,6 +1481,7 @@ class AirTextInquiryFlow:
reply,
work_order_no=sess.work_order_no,
quote=quote,
facts=facts,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status="已报价",
@@ -1491,6 +1494,7 @@ class AirTextInquiryFlow:
*,
work_order_no: str,
quote: dict[str, Any],
facts: dict[str, str] | None = None,
first_or_same: str = "first",
history_work_order_no: str = "",
status: str = "已报价",
@@ -1506,6 +1510,7 @@ class AirTextInquiryFlow:
copy.sea_quote_detail_text(
work_order_no=work_order_no,
quote=src,
facts=facts,
first_or_same=first_or_same,
history_work_order_no=history_work_order_no,
status=status,
+78 -36
View File
@@ -465,10 +465,10 @@ def ask_land_fields(
陆运货物缺项补问。不出核对卡、不出现确定句。
已识别和待补充都标明必填/非必填。跨境/中港空着的通关口岸也列进待补充。
报价日期不进待补充。
报价日期不进待补充。货物品名只在前面的选项清单里问,这里不再放进待填框。
"""
src = dict(facts or {})
skip = {"报价日期"}
skip = {"报价日期", "品名"}
missing = [k for k in missing_keys if k and k not in skip]
land_type = (land_subtype or src.get("运输类型") or "").strip()
optional = _land_optional_keys(land_type)
@@ -1220,26 +1220,29 @@ def sea_quote_detail_text(
history_work_order_no: str = "",
status: str = "已报价",
business_line: str = "SEA",
facts: dict[str, str] | None = None,
) -> str:
"""
完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。
完整价格明细文本。海运/陆运仍是来源、工单号、费用行、总价。
空运改成货物信息 + 价格方案。航司、计费重、货型和费用来自 TMS,其余用工单字段。
陆运分段时效跟在各段费用后面,用 TMS 原文;报价有效期写在总成本价下面。
海运时效、报价有效期仍只在按钮卡副标题。
空运:业务确认没有时效,总销售价下面只留报价有效期。
不受卡片引用条字数限制。first_or_same 只兼容调用方。
"""
_ = first_or_same, history_work_order_no, status
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
lines = [source, f"{work_order_no}价格明细"]
line = (business_line or "").upper()
if line == "AIR":
from agent.schema.air_route_options import route_codes
from agent.policy.air_quote_copy import air_private_quote_text
route = route_codes(None, {}, src)
if route:
lines.append(route)
return air_private_quote_text(
work_order_no=work_order_no,
facts=facts,
quote=src,
)
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
lines = [source, f"{work_order_no}价格明细"]
if line == "LAND":
from agent.schema.land_route_options import land_leg_fee_blocks
@@ -1250,8 +1253,8 @@ def sea_quote_detail_text(
if land_line and len(blocks) != 1:
lines.append(land_line)
lines.append(quote_area_fee_text(src, business_line=business_line or "SEA"))
# 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。
if line in {"AIR", "LAND"}:
# 陆运价格明细正文带有效期。海运仍放在按钮卡副标题。空运已在上面单独成文。
if line == "LAND":
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
return "\n".join(lines)
@@ -3011,12 +3014,12 @@ def air_group_brief(
airline_names: list[str] | None = None,
) -> str:
"""
空运群激活摘要:与海运进群同一套结构,字段用空运。
空运群激活摘要。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
TMS 段跟单聊同一套线路分项,总销售价只一行。
已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。
不写「当前工单/询价要点/现在等到哪一步」。
有 TMS 报价:货物信息(含航司、计费重、货型)+ 协同补充 + 新价格段。
没有 TMS 报价:仍是「询价字段」+「TMS 暂无匹配报价」。
品名正好是普货且没人补过时,含电含磁仍写「否」。
人工报价、销售调价有则接在后面,写法不改。
"""
no = (work_order_no or "").strip()
at_bits: list[str] = []
@@ -3027,10 +3030,28 @@ def air_group_brief(
token = f"@{(name or '').strip()}"
if (name or "").strip() and token not in at_bits:
at_bits.append(token)
lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"]
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:]
tms, manual, adjust = _split_brief_quotes(
quote=quote,
tms_quote=tms_quote,
product_quote=product_quote,
has_price=has_price,
)
if tms:
from agent.schema.air_route_options import project_group_air_quote
tms = project_group_air_quote(tms, facts)
has_tms_price = bool(tms and (tms.get("total") or quote_fee_lines(tms)))
if has_tms_price:
# 有 TMS 报价才换成货物信息。没报价仍走下面的「询价字段」。
from agent.policy.air_quote_copy import air_cargo_lines
lines = ([(" ".join(at_bits))] if at_bits else [])
lines.extend(air_cargo_lines(work_order_no=no, facts=facts, quote=tms))
else:
lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"]
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:]
)
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
@@ -3046,20 +3067,12 @@ def air_group_brief(
if val in {"", "-", "—", "-"}:
val = "-"
lines.append(f"{key}:{val}")
tms, manual, adjust = _split_brief_quotes(
quote=quote,
tms_quote=tms_quote,
product_quote=product_quote,
has_price=has_price,
)
if tms:
from agent.schema.air_route_options import project_group_air_quote
tms = project_group_air_quote(tms, facts)
lines.append("")
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms, business_line="AIR"))
if has_tms_price:
from agent.policy.air_quote_copy import air_price_lines
lines.extend(air_price_lines(tms, facts))
lines.append(group_tms_confirm_hint(business_line="AIR"))
else:
lines.append("TMS 暂无匹配报价")
@@ -3235,16 +3248,37 @@ def air_lock_ok(
option_no: str,
exec_no: str,
quote_no: str,
origin: str = "",
destination: str = "",
airline: str = "",
flight_date: str = "",
) -> str:
"""空运锁舱成功。后台 inquiring.air_lock_ok。"""
"""
空运锁舱成功。后台 inquiring.air_lock_ok。
回执在报价编号下补起运地、目的地、航司、航班日期。
后台旧模板没有这四行时,这里补上,避免被旧句盖掉。
不编造空值;没取到就留空。
"""
origin_text = (origin or "").strip()
destination_text = (destination or "").strip()
airline_text = (airline or "").strip()
flight_text = (flight_date or "").strip()
route = (
f"起运地:{origin_text}\n"
f"目的地:{destination_text}\n"
f"航司:{airline_text}\n"
f"航班日期:{flight_text}"
)
default = (
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
"TMS空运锁舱成功。\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
f"报价编号:{quote_no}\n"
f"{route}"
)
return _r(
text = _r(
"inquiring.air_lock_ok",
default,
ticket_no=work_order_no,
@@ -3252,7 +3286,15 @@ def air_lock_ok(
option_no=option_no,
exec_no=exec_no,
quote_no=quote_no,
origin=origin_text,
destination=destination_text,
airline=airline_text,
flight_date=flight_text,
)
body = (text or "").rstrip()
if "起运地:" in body and "航班日期:" in body:
return body
return f"{body}\n{route}" if body else route
# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。
+11 -3
View File
@@ -1,7 +1,7 @@
"""
陆运文字询价确定性流程。
本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
本文件职责:三项选项和货物品名同一张清单各自必填(不联动)→ 按类型补始发站等货物 → 无工单号核对卡 →(确定后)建单查 TMS。
查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。
查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路。海运私聊同样是核对后确定再查价。
@@ -27,7 +27,10 @@ from agent.policy.air_text_flow import (
from agent.policy.sea_text_flow import SeaTextInquiryFlow
from agent.schema.field_validate import harvest_oral_measures, validate_required_fields
from agent.schema.land_options import (
drop_blank_paste_options,
drop_land_prompt_echo,
harvest_land_options,
land_cargo_name,
looks_like_confirm,
option_prompt,
sample_pair_allowed,
@@ -170,6 +173,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
)
if b_opt:
facts = harvest_land_options(b_opt, facts)
# 复制清单冒号空着的项,菜单文字和模型猜测都不能当成已填。
facts = drop_blank_paste_options(facts, source)
if not mode:
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
@@ -313,7 +318,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
created = self._ledger.create_ticket(
sender_id=sender_id,
business_line="LAND",
facts=facts,
facts=drop_land_prompt_echo(facts),
idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}",
)
no = str(created.get("work_order_no") or "").strip()
@@ -582,7 +587,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
merged.update(opt.get("normalized") or {})
if opt.get("errors"):
return {"ready": False, "opt": opt, "facts": merged, "stage": "options_error"}
if not opt.get("ok"):
# 货物品名和三项同一张清单。没填上继续发这张,不挪到始发站补问。
if not opt.get("ok") or not land_cargo_name(merged):
return {"ready": False, "opt": opt, "facts": merged, "stage": "options_missing"}
land_type = (merged.get("运输类型") or "").strip()
check = validate_required_fields(
@@ -658,6 +664,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
return "need_land_options"
if state["stage"] == "options_missing":
keys = list((state.get("opt") or {}).get("missing") or [])
if not land_cargo_name(facts):
keys.append("品名")
logger.info("land.options_missing missing=%s", keys)
new_sess = FlowSession(
sender_id=sender_id,
+46 -25
View File
@@ -20,7 +20,10 @@ from agent.schema.field_validate import (
)
from agent.llm.extract_text import parse_labeled_facts
from agent.schema.land_options import (
drop_blank_paste_options,
drop_land_prompt_echo,
harvest_land_options,
land_cargo_name,
option_prompt,
scrub_land_placeholder_ports,
validate_land_options,
@@ -220,15 +223,26 @@ def _segment_required_check(seg: Segment) -> tuple[dict[str, Any], dict[str, str
)
)
option_missing = [str(x) for x in (opt.get("missing") or []) if str(x).strip()]
cargo_missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()]
missing = option_missing + [k for k in cargo_missing if k not in option_missing]
cargo_missing = [
str(x)
for x in (check.get("missing") or [])
if str(x).strip() and str(x) not in option_missing and str(x) != "品名"
]
# 货物品名没填时仍算这张选项清单没完成,不进始发站待填。
cargo_name = land_cargo_name(facts)
missing = list(option_missing) + cargo_missing
if not cargo_name:
missing.append("品名")
merged = dict(check.get("facts") or facts)
merged.update({k: facts[k] for k in ("运输类型", "线路类别", "运输分类") if facts.get(k)})
for key, val in facts.items():
if str(key).startswith("_land_show_") and str(val).strip():
merged[str(key)] = str(val).strip()
check["facts"] = merged
check["missing"] = missing
check["ok"] = not missing
check["need_land_subtype"] = False
check["need_land_options"] = bool(option_missing)
check["need_land_options"] = bool(option_missing) or not cargo_name
return check, merged
check = validate_required_fields(facts=dict(seg.facts), business_line=seg.mode)
facts = dict(seg.facts)
@@ -342,7 +356,10 @@ def _overlay_segments(
continue
blob = (neu.source or "").strip()
if target.mode == "LAND":
overlay = harvest_land_options(blob, dict(neu.facts))
overlay = drop_blank_paste_options(
harvest_land_options(blob, dict(neu.facts)),
blob,
)
elif target.mode == "SEA":
overlay = harvest_sea_class(blob, dict(neu.facts))
else:
@@ -412,16 +429,23 @@ def group_brief(
return "\n".join(lines)
def segments_to_payload(segments: list[Segment]) -> list[dict[str, Any]]:
"""书签和工单里保存的段。只保留字符串,避免书签序列化掉类型。"""
return [
{
"index": seg.index,
"mode": seg.mode,
"facts": {str(k): str(v) for k, v in (seg.facts or {}).items() if str(v).strip()},
}
for seg in segments
]
def segments_to_payload(
segments: list[Segment],
*,
keep_prompt_echo: bool = True,
) -> list[dict[str, Any]]:
"""
书签和工单里保存的段。只保留字符串,避免书签序列化掉类型。
建单时 keep_prompt_echo=False,回显用的内部键不进工单。
"""
rows: list[dict[str, Any]] = []
for seg in segments:
facts = {str(k): str(v) for k, v in (seg.facts or {}).items() if str(v).strip()}
if not keep_prompt_echo:
facts = drop_land_prompt_echo(facts)
rows.append({"index": seg.index, "mode": seg.mode, "facts": facts})
return rows
def segments_from_payload(raw: Any) -> list[Segment]:
@@ -462,16 +486,13 @@ def _segment_clarify(seg: Segment, check: dict[str, Any]) -> str:
if check.get("ok") and not check.get("need_land_subtype"):
return ""
missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()]
if check.get("need_land_subtype"):
return (
copy.CLARIFY_PASTE_HINT
+ "\n1.运输类型(必填):\n(如:国内运输拼车)"
)
if seg.mode == "LAND" and (
check.get("need_land_subtype") or check.get("need_land_options")
):
# 和单聊同一张清单:类型、线路、分类、货物品名。已填的留在复制框上面。
return option_prompt(current=seg.facts)
if seg.mode == "LAND":
if check.get("need_land_options"):
# 和单聊一样先列出线路类别、运输分类,选完再问询价字段。
return option_prompt(current=seg.facts)
cargo_missing = [k for k in missing if k not in _LAND_OPTION_KEYS]
cargo_missing = [k for k in missing if k not in _LAND_OPTION_KEYS and k != "品名"]
raw = copy.ask_land_fields(
facts=seg.facts,
missing_keys=cargo_missing,
@@ -594,7 +615,7 @@ def _facts_from_body(body: str, mode: str) -> dict[str, str]:
if val and not str(facts.get(key) or "").strip():
facts[key] = val
if mode == "LAND":
facts = harvest_land_options(body, facts)
facts = drop_blank_paste_options(harvest_land_options(body, facts), body)
if mode == "SEA":
facts = harvest_sea_class(body, facts)
return facts
@@ -624,7 +645,7 @@ def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]:
end = marks[i + 1].start() if i + 1 < len(marks) else len(raw)
body = raw[mark.end() : end]
facts = parse_labeled_facts(body)
facts = harvest_land_options(body, facts)
facts = drop_blank_paste_options(harvest_land_options(body, facts), body)
facts = harvest_sea_class(body, facts)
facts = harvest_oral_measures(body, facts)
if facts:
@@ -414,7 +414,7 @@ class MultiTextInquiryFlow:
self._put_segments(sess, updated)
self._save(sess)
return "clarify"
payload = segments_to_payload(updated)
payload = segments_to_payload(updated, keep_prompt_echo=False)
created = self._ledger.create_ticket(
sender_id=sess.sender_id,
business_line="MULTI",
+13 -1
View File
@@ -954,7 +954,19 @@ def _resume_cabin_step(
facts={"__air_lock_id": exec_no, "__air_lock_option": option_no, "__pending_air_lock": ""},
sender_id=sender,
)
reply(copy.air_lock_ok(work_order_no=no, operator="同事", option_no=option_no, exec_no=exec_no, quote_no=""))
reply(
copy.air_lock_ok(
work_order_no=no,
operator="同事",
option_no=option_no,
exec_no=exec_no,
quote_no="",
origin=str(extra.get("origin") or ""),
destination=str(extra.get("destination") or ""),
airline=str(extra.get("airline") or ""),
flight_date=str(extra.get("flight_date") or ""),
)
)
else:
reply(copy.air_release_ok(work_order_no=no, operator="同事", option_no="", exec_no=str((out or {}).get("releaseId") or lock_id), quote_no=""))
return {"ok": True, "step": cabin_step}