调价后的新价格写入报价单;多段段前的货物带到各段,协同群不再把体积里的星号吃掉。海运字段齐了先核对再查价。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -116,6 +116,7 @@ def activate_ticket(
|
||||
把本群当前工单切到指定空运未结案单。
|
||||
|
||||
返回 (工单, phase)。失败时工单为 None。
|
||||
航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。
|
||||
"""
|
||||
book = flow.ledger
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -156,6 +157,12 @@ def activate_ticket(
|
||||
return None, "handoff_ignore"
|
||||
# 先落默认协同,再决定要不要发摘要。发文件切单不说话,工单上仍要有含电/含磁。
|
||||
_seed_air_activate_collab(flow, ticket)
|
||||
if speak and _air_already_quoted_for_deal(ticket):
|
||||
view = ticket_view(ticket)
|
||||
view["collab_chat_id"] = chat_id
|
||||
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
||||
_watch_archive_room(chat_id, no)
|
||||
return ticket, "activated_deal"
|
||||
if speak:
|
||||
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
_watch_archive_room(chat_id, no)
|
||||
@@ -165,6 +172,25 @@ def activate_ticket(
|
||||
return ticket, "activated"
|
||||
|
||||
|
||||
def _air_already_quoted_for_deal(ticket: object) -> bool:
|
||||
"""
|
||||
航线报价已经落账,再激活应回成交跟进。
|
||||
|
||||
附件报价会把来源标成「航线附件报价」,但报价编号仍可能留着 TMS。
|
||||
只看 source 会误当成还没报价。还只有 TMS 标准价时仍发模板。
|
||||
"""
|
||||
status = ticket_status(ticket)
|
||||
if status == "协商中":
|
||||
return True
|
||||
if status != "已报价":
|
||||
return False
|
||||
quote = dict(ticket_view(ticket).get("quote") or {})
|
||||
label = str(quote.get("source_label") or quote.get("sourceLabel") or "")
|
||||
if "航线" in label or "产品" in label:
|
||||
return True
|
||||
return copy.quote_is_sales_adjust(quote)
|
||||
|
||||
|
||||
def _collab_slot_empty(val: object) -> bool:
|
||||
"""协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。"""
|
||||
return str(val or "").strip() in {"", "-", "—", "-"}
|
||||
|
||||
@@ -64,6 +64,8 @@ class FlowSession:
|
||||
handoff_offer: str = ""
|
||||
handoff_committed: bool = False
|
||||
virtual_work_order_no: str = ""
|
||||
# 核对卡还没有工单号时,图片/附件先记在这里,确定建单后再挂到主账。
|
||||
pending_files: tuple[dict[str, str], ...] = ()
|
||||
|
||||
|
||||
def is_waiting_mode_session(sess) -> bool:
|
||||
@@ -139,6 +141,15 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
|
||||
handoff_offer=str(raw.get("handoff_offer") or ""),
|
||||
handoff_committed=bool(raw.get("handoff_committed")),
|
||||
virtual_work_order_no=str(raw.get("virtual_work_order_no") or ""),
|
||||
pending_files=tuple(
|
||||
{
|
||||
"file_name": str(item.get("file_name") or ""),
|
||||
"object_key": str(item.get("object_key") or ""),
|
||||
"content_type": str(item.get("content_type") or ""),
|
||||
}
|
||||
for item in (raw.get("pending_files") or [])
|
||||
if isinstance(item, dict) and str(item.get("object_key") or "").strip()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +185,29 @@ class AirTextInquiryFlow:
|
||||
"""空运群 BOT 出站;单测注入假客户端。"""
|
||||
return self._group_client
|
||||
|
||||
def remember_pending_files(self, sender_id: str, files: list[dict[str, str]]) -> None:
|
||||
"""
|
||||
核对卡阶段还没有工单号,先把图片/附件键记在当前书签上。
|
||||
|
||||
确定建单后再挂主账。副作用:覆盖该书签的 pending_files 并持久化。
|
||||
"""
|
||||
sess = self.session_of(sender_id)
|
||||
if sess is None:
|
||||
return
|
||||
cleaned = tuple(
|
||||
{
|
||||
"file_name": str(item.get("file_name") or ""),
|
||||
"object_key": str(item.get("object_key") or ""),
|
||||
"content_type": str(item.get("content_type") or ""),
|
||||
}
|
||||
for item in files
|
||||
if str(item.get("object_key") or "").strip()
|
||||
)
|
||||
if not cleaned:
|
||||
return
|
||||
sess.pending_files = cleaned
|
||||
self._save(sess)
|
||||
|
||||
def session_of(self, sender_id: str) -> Optional[FlowSession]:
|
||||
sid = (sender_id or "").strip()
|
||||
with self._lock:
|
||||
@@ -417,6 +451,8 @@ class AirTextInquiryFlow:
|
||||
from agent.policy.handoff_human import inherit_pre_create_ticket, note_clarify_round
|
||||
|
||||
if sess is not None:
|
||||
if not new_sess.pending_files:
|
||||
new_sess.pending_files = tuple(getattr(sess, "pending_files", ()) or ())
|
||||
new_sess.clarify_missing_sig = sess.clarify_missing_sig
|
||||
new_sess.clarify_retry = sess.clarify_retry
|
||||
new_sess.handoff_offer = sess.handoff_offer
|
||||
@@ -550,6 +586,7 @@ class AirTextInquiryFlow:
|
||||
按工单号回到当时停点并重出对应卡片。
|
||||
|
||||
先用进程内该书签;没有再读主账推断节点。不重跑 TMS、不新建工单。
|
||||
正在等未成交原因时,原话记成原因,不再把「请补充未成交原因」重发一遍。
|
||||
"""
|
||||
no = (work_order_no or "").strip().upper()
|
||||
if not no:
|
||||
@@ -566,6 +603,9 @@ class AirTextInquiryFlow:
|
||||
closed = self._close_spoken_deal(keep, text, reply)
|
||||
if closed:
|
||||
return closed
|
||||
taken = self._resume_lost_reason(keep, text, reply)
|
||||
if taken:
|
||||
return taken
|
||||
return self._replay_wait(keep, reply, source="session")
|
||||
getter = getattr(self._ledger, "get_for_agent", None)
|
||||
if not callable(getter):
|
||||
@@ -584,6 +624,9 @@ class AirTextInquiryFlow:
|
||||
closed = self._close_spoken_deal(sess, text, reply)
|
||||
if closed:
|
||||
return closed
|
||||
taken = self._resume_lost_reason(sess, text, reply)
|
||||
if taken:
|
||||
return taken
|
||||
return self._replay_wait(sess, reply, source="ledger")
|
||||
|
||||
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
|
||||
@@ -652,6 +695,26 @@ class AirTextInquiryFlow:
|
||||
logger.info("resume 报价文件已出,回到成交跟进 wo=%s was=%s", sess.work_order_no, sess.phase)
|
||||
self._promote_wait_deal(sess)
|
||||
|
||||
def _resume_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
|
||||
"""
|
||||
等未成交原因时,续办带来的原话就是原因。
|
||||
|
||||
短句常被判成继续工单。继续工单若只重发提示,原因不会入账。
|
||||
原话只有工单号时仍重发提示,不把工单号当成原因。
|
||||
"""
|
||||
if (sess.phase or "") != "wait_lost_reason":
|
||||
return ""
|
||||
body = (text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
named = copy.extract_work_order_no(body)
|
||||
leftover = body
|
||||
if named:
|
||||
leftover = body.replace(named, "", 1).strip(" ,,。.;;")
|
||||
if not leftover:
|
||||
return ""
|
||||
return self._capture_lost_reason(sess, leftover, reply)
|
||||
|
||||
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
|
||||
"""按书签重出当时节点,不改六态。"""
|
||||
logger.info(
|
||||
|
||||
@@ -51,6 +51,9 @@ _AIR_OPTIONAL_CLARIFY = tuple(
|
||||
)
|
||||
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
|
||||
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
|
||||
# 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。
|
||||
SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
|
||||
SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。"
|
||||
|
||||
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
|
||||
_SEA_EXAMPLES = {
|
||||
@@ -478,6 +481,26 @@ def air_confirm_card(facts: dict[str, str] | None) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sea_confirm_card(facts: dict[str, str] | None) -> str:
|
||||
"""
|
||||
海运确定查价前的核对卡。没有工单号。
|
||||
|
||||
销售侧必填都列出(货物数量对应内部键货量)。报价日期没说则用已经填上的当天。
|
||||
协同字段不在这张卡上,不挡首次查价。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
lines = ["运输方式(必填):海运"]
|
||||
for key, label in SEA_REQUIRED_FIELDS:
|
||||
if key == "货量":
|
||||
val = (src.get("货量") or src.get("货物数量") or "").strip()
|
||||
else:
|
||||
val = (src.get(key) or "").strip()
|
||||
lines.append(f"{label}(必填):{val}")
|
||||
lines.append("")
|
||||
lines.append(SEA_CONFIRM_TAIL)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ask_clarify(
|
||||
*,
|
||||
facts: dict[str, str] | None = None,
|
||||
@@ -1032,34 +1055,28 @@ def quote_area_fee_text(
|
||||
return "\n".join(fee_lines)
|
||||
|
||||
|
||||
# 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。
|
||||
_QUOTE_AREA_SAFE_CHARS = 80
|
||||
|
||||
|
||||
def quote_area_safe_text(
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str = "",
|
||||
transport_mode: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
卡片引用条:空运有费用行就只留总价,禁止再塞费用。
|
||||
价格明细卡的引用条只留两行:费用合计、共几项费用。
|
||||
|
||||
三项短费用也会被企微裁成「人工费:CNY 123...」。完整明细走文本。
|
||||
海运/陆运仍按字数:短的原样,超长同样只留总价。
|
||||
空运、海运、陆运都这样。完整路段和逐项费用在上一条文字里。
|
||||
引用条再塞路段名或费用明细,企微会裁成半句。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
n = len(quote_fee_lines(src))
|
||||
if _is_land_surface(quote=src, business_line=business_line, transport_mode=transport_mode):
|
||||
from agent.schema.land_route_options import land_leg_fee_blocks
|
||||
|
||||
block_n = sum(len(fees) for _title, fees, _eta in land_leg_fee_blocks(src))
|
||||
if block_n:
|
||||
n = block_n
|
||||
label = _total_label(src, business_line, transport_mode)
|
||||
line = (business_line or "").upper()
|
||||
mode = (transport_mode or "").strip()
|
||||
is_air = line == "AIR" or mode == "空运"
|
||||
if is_air and n:
|
||||
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
|
||||
full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode)
|
||||
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
|
||||
return full
|
||||
if n:
|
||||
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
|
||||
return f"{label}:{total}"
|
||||
@@ -1094,8 +1111,13 @@ def sea_quote_detail_text(
|
||||
if route:
|
||||
lines.append(route)
|
||||
if line == "LAND":
|
||||
from agent.schema.land_route_options import land_leg_fee_blocks
|
||||
|
||||
# 只有一段时,费用前的「始发 → 目的」已经是这条线路。
|
||||
# 再写横杠全程(上海-谅山…)就是同一条路出现两次。多段仍保留全程,方便看中转。
|
||||
blocks = land_leg_fee_blocks(src)
|
||||
land_line = str(src.get("route_line") or "").strip()
|
||||
if land_line:
|
||||
if land_line and len(blocks) != 1:
|
||||
lines.append(land_line)
|
||||
lines.append(quote_area_fee_text(src, business_line=business_line or "SEA"))
|
||||
# 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
|
||||
查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。
|
||||
查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。
|
||||
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路;把海运「出卡即查价」改掉。
|
||||
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路。海运私聊同样是核对后确定再查价。
|
||||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||||
"""
|
||||
|
||||
|
||||
@@ -27,13 +27,36 @@ from agent.schema.land_options import (
|
||||
)
|
||||
from agent.schema.sea_options import harvest_sea_class
|
||||
|
||||
# 行首或「段N:」后面的运输方式。排除「空运费」,避免费用词被当成新的一段。
|
||||
_MODE_RE = re.compile(r"(?:段\s*(\d+)\s*[::]\s*)?(空运|海运|陆运)(?!费)")
|
||||
# 已经写了「段1:陆运」时,段内再写「运输方式:海运」不能再切一段。
|
||||
_EXPLICIT_SEG = re.compile(r"段\s*(\d+)\s*[::]\s*(空运|海运|陆运)(?!费)")
|
||||
# 「段1:」或「第一段」。第组是阿拉伯数字,第二组是「一」或单个数字。运输方式在第三组。
|
||||
_SEG_PREFIX = (
|
||||
r"(?:段\s*(\d+)\s*[::]\s*|第\s*([一二三四五六七八九]|\d+)\s*段\s*[::,,]?\s*)"
|
||||
)
|
||||
# 行首或段标后面的运输方式。排除「空运费」,避免费用词被当成新的一段。
|
||||
_MODE_RE = re.compile(rf"(?:{_SEG_PREFIX})?(空运|海运|陆运)(?!费)")
|
||||
# 已经写了「段1:陆运」或「第一段陆运」时,段内再写「运输方式:海运」不能再切一段。
|
||||
_EXPLICIT_SEG = re.compile(rf"{_SEG_PREFIX}(空运|海运|陆运)(?!费)")
|
||||
# 单段标签行没覆盖的陆运销售叫法。内部键仍交给字段校验去归一。
|
||||
_EXTRA_LABEL = re.compile(r"^(始发站|货物品名|运输类型|线路类别)\s*[::]\s*(.+)$")
|
||||
_SEG_MARK = re.compile(r"段\s*(\d+)")
|
||||
_SEG_MARK = re.compile(r"第\s*([一二三四五六七八九]|\d+)\s*段|段\s*(\d+)")
|
||||
_CN_INDEX = {
|
||||
"一": 1,
|
||||
"二": 2,
|
||||
"三": 3,
|
||||
"四": 4,
|
||||
"五": 5,
|
||||
"六": 6,
|
||||
"七": 7,
|
||||
"八": 8,
|
||||
"九": 9,
|
||||
}
|
||||
# 段标前面写一次、两段都要的货物。地名不在这里,避免把北京写进空运起运地。
|
||||
_SHARED_CARGO_KEYS = ("品名", "件数", "毛重", "体积", "包装方式")
|
||||
_SHARED_NOISE = re.compile(
|
||||
r"\d+(?:\.\d+)?(?:\s*[*xX×]\s*\d+(?:\.\d+)?){1,2}"
|
||||
r"|\d+(?:\.\d+)?\s*(?:kgs|kg|公斤|千克|cbm|立方米|立方|m3|m³|方|件|pcs)\b"
|
||||
r"|纸箱|木箱|托盘|卡板|散货",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_MODE_BY_WORD = {"空运": "AIR", "海运": "SEA", "陆运": "LAND"}
|
||||
_WORD_BY_MODE = {"AIR": "空运", "SEA": "海运", "LAND": "陆运"}
|
||||
@@ -85,7 +108,8 @@ def split_segments(text: str) -> SplitOutcome:
|
||||
表里再写「运输方式:海运」仍是单段,不能报同一方式两段。
|
||||
没写段标时,报价单反复出现「海运」也当单段;只有「陆运+海运」才切多段。
|
||||
真的两段都是海运(段1:海运 + 段2:海运)才 same_mode,不建单。
|
||||
每段正文只收「字段名:值」,不猜没写标签的港口。
|
||||
「第一段陆运」和「段1:陆运」是同一种段标。
|
||||
段标前面单独写的品名、件数、重量、体积,每一段都收下。不猜没写标签的港口。
|
||||
"""
|
||||
raw = text or ""
|
||||
explicit = list(_EXPLICIT_SEG.finditer(raw))
|
||||
@@ -93,31 +117,91 @@ def split_segments(text: str) -> SplitOutcome:
|
||||
hits = explicit if explicit else list(_MODE_RE.finditer(raw))
|
||||
if len(hits) < 2:
|
||||
return SplitOutcome()
|
||||
modes = [_MODE_BY_WORD[hit.group(2)] for hit in hits]
|
||||
modes = [_MODE_BY_WORD[hit.group(3)] for hit in hits]
|
||||
unique = set(modes)
|
||||
if len(modes) != len(unique):
|
||||
# 没标出两段时,同一种方式写两次仍是单段(海运报价单标题+运输方式)。
|
||||
if len(explicit) < 2 and len(unique) == 1:
|
||||
return SplitOutcome()
|
||||
return SplitOutcome(same_mode=True)
|
||||
shared = _shared_cargo_facts(raw[: hits[0].start()])
|
||||
lead = raw[: hits[0].start()].strip()
|
||||
segments: list[Segment] = []
|
||||
for i, hit in enumerate(hits):
|
||||
end = hits[i + 1].start() if i + 1 < len(hits) else len(raw)
|
||||
body = raw[hit.end() : end]
|
||||
explicit = hit.group(1)
|
||||
index = int(explicit) if explicit else i + 1
|
||||
mode = modes[i]
|
||||
# 抽取要看得到段前那句货物,否则「茶叶,11kg」写在第一段前面会被丢掉。
|
||||
source = f"{lead}\n{body}".strip() if lead else body
|
||||
segments.append(
|
||||
Segment(
|
||||
index=index,
|
||||
mode=modes[i],
|
||||
facts=_facts_from_body(body, modes[i]),
|
||||
source=body,
|
||||
index=_segment_index(hit, i + 1),
|
||||
mode=mode,
|
||||
facts=_fill_blank(_facts_from_body(body, mode), shared),
|
||||
source=source,
|
||||
)
|
||||
)
|
||||
segments.sort(key=lambda seg: seg.index)
|
||||
return SplitOutcome(ok=True, segments=segments)
|
||||
|
||||
|
||||
def _segment_index(hit: re.Match[str], fallback: int) -> int:
|
||||
"""段标上的序号。没有段标就按出现顺序,从 1 起。"""
|
||||
token = hit.group(1) or hit.group(2) or ""
|
||||
if token in _CN_INDEX:
|
||||
return _CN_INDEX[token]
|
||||
if token.isdigit():
|
||||
return int(token)
|
||||
return fallback
|
||||
|
||||
|
||||
def _shared_cargo_facts(header: str) -> dict[str, str]:
|
||||
"""
|
||||
段标前面写一次的货物。
|
||||
|
||||
件数、重量、体积、包装用口语机读。尺寸 1.1*2.1*2.2 保持原文,不乘成立方。
|
||||
去掉这些之后只剩一个词,就当品名。带「到」的地名不当品名。
|
||||
只返回货物键,不把起运地抄到每一段。
|
||||
"""
|
||||
raw = (header or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
measured = harvest_oral_measures(raw, {})
|
||||
name = _cargo_name_before_segments(raw)
|
||||
if name and not str(measured.get("品名") or "").strip():
|
||||
measured["品名"] = name
|
||||
return {
|
||||
key: str(measured.get(key) or "").strip()
|
||||
for key in _SHARED_CARGO_KEYS
|
||||
if str(measured.get(key) or "").strip()
|
||||
}
|
||||
|
||||
|
||||
def _cargo_name_before_segments(raw: str) -> str:
|
||||
"""段前货物句里,去掉件数重量体积包装后剩下的那一个品名。"""
|
||||
cleaned = _SHARED_NOISE.sub(" ", raw or "")
|
||||
parts: list[str] = []
|
||||
for part in re.split(r"[,,、;;\n]+", cleaned):
|
||||
token = part.strip(" ::。.")
|
||||
if not token or re.fullmatch(r"[\d.\s*xX×]+", token):
|
||||
continue
|
||||
if any(word in token for word in ("到", "飞", "空运", "海运", "陆运", "段")):
|
||||
continue
|
||||
parts.append(token)
|
||||
if len(parts) == 1 and len(parts[0]) <= 30:
|
||||
return parts[0]
|
||||
return ""
|
||||
|
||||
|
||||
def _fill_blank(base: dict[str, str], extra: dict[str, str]) -> dict[str, str]:
|
||||
"""只补还空着的键。段内已经写了的品名、港口不被段前那句盖掉。"""
|
||||
out = dict(base)
|
||||
for key, val in extra.items():
|
||||
if str(val or "").strip() and not str(out.get(key) or "").strip():
|
||||
out[key] = str(val).strip()
|
||||
return out
|
||||
|
||||
|
||||
def _segment_required_check(seg: Segment) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
"""
|
||||
一段是否齐了询价必填。陆运还要三项选项,模板码站点当没填。
|
||||
@@ -513,6 +597,14 @@ def _facts_from_body(body: str, mode: str) -> dict[str, str]:
|
||||
return facts
|
||||
|
||||
|
||||
def _mark_index(mark: re.Match[str]) -> int:
|
||||
"""「段1」或「第一段」的序号。调用前已经匹配到段标。"""
|
||||
token = mark.group(1) or mark.group(2) or ""
|
||||
if token in _CN_INDEX:
|
||||
return _CN_INDEX[token]
|
||||
return int(token)
|
||||
|
||||
|
||||
def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]:
|
||||
"""
|
||||
补充消息里「段1」「段2」后面的内容。
|
||||
@@ -533,5 +625,5 @@ def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]:
|
||||
facts = harvest_sea_class(body, facts)
|
||||
facts = harvest_oral_measures(body, facts)
|
||||
if facts:
|
||||
out[int(mark.group(1))] = facts
|
||||
out[_mark_index(mark)] = facts
|
||||
return out
|
||||
|
||||
@@ -1608,7 +1608,9 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
|
||||
out["total"] = total
|
||||
if currency:
|
||||
out["currency"] = currency
|
||||
return out
|
||||
from agent.schema.quote_adjust import stamp_prices_for_sheet
|
||||
|
||||
return stamp_prices_for_sheet(out)
|
||||
|
||||
|
||||
def _save_product_quote(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
海运文字询价确定性流程。
|
||||
|
||||
本文件职责:海运七项补问 → 建单出确认卡 → 主账查 TMS → 有价/无价卡 → 跳过协同或拉群。
|
||||
本文件职责:海运补问 → 无工单号核对卡 → 回复确定后建单查 TMS → 有价/无价卡 → 跳过协同或拉群。
|
||||
空运 Owner 仍在 air_text_flow,禁止把海运再堆回去。
|
||||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||||
禁止:直连 TMS;本地拆箱型;用户文字冒充群报价版本。
|
||||
@@ -108,6 +108,21 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
adjusted = try_private_adjust(self, sess, text, reply)
|
||||
if adjusted:
|
||||
return adjusted
|
||||
from agent.schema.land_options import looks_like_confirm
|
||||
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
|
||||
logger.info("sea.confirm sender=%s", sender_id)
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
text=sess.immutable_text or text,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
if not sess and looks_like_confirm(text):
|
||||
reply(copy.SEA_CONFIRM_EXPIRED)
|
||||
return "confirm_expired"
|
||||
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
@@ -132,7 +147,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode"}:
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
|
||||
@@ -205,7 +220,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
# 整句只回「港到港」是贸易条款举例,不要改写成运输分类。
|
||||
class_source = "" if short_sea_trade_reply(source) == "港到港" else source
|
||||
facts = harvest_sea_class(class_source, facts)
|
||||
# 贸易条款标签行原样优先;整句回复门到门/港到港要收下;没写这两个词时模型冒充的门到门丢掉。
|
||||
# 贸易条款标签行原样优先;整句回复或长句里写了门到门/港到港都要收下。
|
||||
# 原话没出现这两个词时,模型冒充的门到门、港到港继续丢掉。
|
||||
facts = apply_spoken_sea_trade_reply(source, facts)
|
||||
spoken = harvest_spoken_trade_term(source)
|
||||
if spoken.get("贸易条款") and not str(facts.get("贸易条款") or "").strip():
|
||||
@@ -243,13 +259,109 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
),
|
||||
)
|
||||
|
||||
return self._create_and_quote(
|
||||
return self._hold_for_confirm(
|
||||
sender_id=sender_id,
|
||||
facts=facts,
|
||||
text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
|
||||
def _hold_for_confirm(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
facts: dict[str, str],
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
sess: Optional[FlowSession],
|
||||
) -> str:
|
||||
"""必填已齐:出海运核对卡,等确定再建单。核对卡没有工单号。"""
|
||||
reply(copy.sea_confirm_card(facts))
|
||||
pending = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="wait_confirm",
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
pending_files=pending,
|
||||
)
|
||||
)
|
||||
return "wait_confirm"
|
||||
|
||||
def _confirm_or_hold(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
facts: dict[str, str],
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
sess: FlowSession,
|
||||
) -> str:
|
||||
"""确定意图:字段仍齐则建单查价。缺了就回到补问,不建单。"""
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
tms_ready = assemble_sea_query(check["facts"])
|
||||
ready_facts = dict(tms_ready.get("facts") or facts)
|
||||
if not check["ok"] or not tms_ready.get("ok"):
|
||||
keys = list(check["missing"] or [])
|
||||
for key in tms_ready.get("missing") or []:
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
new_sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="SEA",
|
||||
facts=ready_facts,
|
||||
immutable_text=sess.immutable_text or text,
|
||||
wait_version=sess.wait_version + 1,
|
||||
)
|
||||
return self._clarify_or_handoff(
|
||||
sess=sess,
|
||||
keys=keys,
|
||||
new_sess=new_sess,
|
||||
reply=reply,
|
||||
clarify_text=copy.ask_clarify(
|
||||
facts=new_sess.facts,
|
||||
missing_keys=keys,
|
||||
transport_mode="海运",
|
||||
),
|
||||
)
|
||||
return self._create_and_quote(
|
||||
sender_id=sender_id,
|
||||
facts=dict(tms_ready["facts"]),
|
||||
text=text,
|
||||
reply=reply,
|
||||
)
|
||||
|
||||
def _flush_pending_files(self, sender_id: str, work_order_no: str) -> None:
|
||||
"""确定建单后,把核对前收下的图片/附件挂到这张工单。挂完即清,避免重试重复挂。"""
|
||||
sess = self.session_of(sender_id)
|
||||
files = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
|
||||
if not files or not (work_order_no or "").strip():
|
||||
return
|
||||
for row in files:
|
||||
key = str(row.get("object_key") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
self._ledger.attach_file(
|
||||
work_order_no=work_order_no,
|
||||
file_name=str(row.get("file_name") or "") or "attachment.bin",
|
||||
object_key=key,
|
||||
content_type=str(row.get("content_type") or "") or "application/octet-stream",
|
||||
sender_id=sender_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("海运核对后挂附件失败 wo=%s", work_order_no)
|
||||
if sess is not None:
|
||||
sess.pending_files = ()
|
||||
self._save(sess)
|
||||
|
||||
def _create_and_quote(
|
||||
self,
|
||||
*,
|
||||
@@ -266,6 +378,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
old = self.session_of(sender_id)
|
||||
if old:
|
||||
void_virtual_no(old)
|
||||
pending = tuple(getattr(old, "pending_files", ()) or ()) if old else ()
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
@@ -274,6 +387,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=text,
|
||||
pending_files=pending,
|
||||
)
|
||||
)
|
||||
created = self._ledger.create_ticket(
|
||||
@@ -286,6 +400,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if not no:
|
||||
reply("建单失败,请稍后把刚才那条再发一次。海运字段已经记下。")
|
||||
return "ledger_fail"
|
||||
self._flush_pending_files(sender_id, no)
|
||||
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
|
||||
out_id = reply(
|
||||
copy.inquiry_card(
|
||||
|
||||
Reference in New Issue
Block a user