调价后的新价格写入报价单;多段段前的货物带到各段,协同群不再把体积里的星号吃掉。海运字段齐了先核对再查价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 22:39:54 +08:00
co-authored by Cursor
parent 0a14b4cc15
commit 7c77e45e03
32 changed files with 1384 additions and 75 deletions
+9 -1
View File
@@ -14,7 +14,7 @@ if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from agent.channel.aibot import normalize_aibot_payload
from agent.channel.aibot.reply import AibotReplyClient
from agent.channel.aibot.reply import AibotReplyClient, protect_markdown_asterisks
class AibotInboundNormalizeTests(unittest.TestCase):
@@ -51,6 +51,14 @@ class AibotInboundNormalizeTests(unittest.TestCase):
class AibotReplyTests(unittest.TestCase):
def test_group_markdown_keeps_dimension_asterisks(self) -> None:
raw = "体积(CBM)(必填):1.1*2.1*2.1\n箱型箱量:40HQ*1"
shown = protect_markdown_asterisks(raw)
self.assertEqual(
shown,
"体积(CBM)(必填):`1.1*2.1*2.1`\n箱型箱量:`40HQ*1`",
)
self.assertEqual(protect_markdown_asterisks(shown), shown)
def test_empty_content_skips(self) -> None:
client = AibotReplyClient("")
out = client.send_group(chat_id="wr_1", content=" ")
@@ -1091,6 +1091,31 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts()))
def test_reactivate_after_airline_file_is_deal_not_blank_sheet(self) -> None:
"""航线附件已报价后,再发工单号回到成交跟进,不再要航线报价。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af3b"),
flow=self.flow,
ledger=self.ledger,
)
handle_air_group(
self._file(sender="air1", filename="航线报价.pdf", chat="wr_air_1", mid="af4b"),
flow=self.flow,
ledger=self.ledger,
)
before = len(self.groups.sent)
phase = handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="af4c"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "activated_deal")
added = self.groups.sent[before:]
texts = "\n".join(x.get("content") or "" for x in added)
self.assertIn("请确认成交状态", texts)
self.assertFalse(any(x.get("kind") == "file" for x in added))
def test_image_rejected(self) -> None:
wo = self._create_air()
handle_air_group(
@@ -1376,6 +1401,45 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined)
self.assertNotIn("TMS接口异常,IT运维排查中", joined)
def test_paused_after_lock_still_records_lost_reason(self) -> None:
"""锁舱失败标了暂停后,未成交和原因仍要入账,不能回 TMS 异常。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_paused_lost", mid="pl0"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_activate",
)
self.ledger.mark_system_exception(
work_order_no=wo,
system_exception="TMS",
system_exception_reason="空运锁舱接口调用失败",
step="tms_lock",
payload={},
)
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="sales1", text="未成交", chat="wr_air_paused_lost", mid="pl1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_lost",
)
self.assertEqual(phase, "wait_lost_reason")
self.assertNotIn("TMS接口异常", "\n".join(self._texts()))
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="sales1", text="客户不需要了", chat="wr_air_paused_lost", mid="pl2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="other",
)
self.assertEqual(phase, "done")
joined = "\n".join(self._texts())
self.assertIn("已记下未成交原因:客户不需要了", joined)
self.assertNotIn("TMS接口异常", joined)
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.lost_reason, "客户不需要了")
def test_release_fail_tells_operator_and_tms_reason(self) -> None:
"""释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。"""
wo = self._create_air()
@@ -213,6 +213,15 @@ class AttachmentInquiryTests(unittest.TestCase):
self.assertIn(copy.ATTACH_RECOGNIZING, self._bodies())
sess = get_sea_text_flow().session_of("s1")
self.assertIsNotNone(sess)
self.assertEqual(sess.phase, "wait_confirm")
self.assertFalse((sess.work_order_no or "").strip())
self.assertTrue(sess.pending_files)
self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1])
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"),
store=self.store,
)
sess = get_sea_text_flow().session_of("s1")
self.assertTrue((sess.work_order_no or "").strip())
ticket = get_sea_text_flow().ledger.get_ticket(work_order_no=sess.work_order_no)
self.assertTrue(ticket.attachments)
@@ -267,6 +276,13 @@ class AttachmentInquiryTests(unittest.TestCase):
self.assertEqual(len(acks), 1)
self.assertEqual(acks[0], copy.IMAGE_RECOGNIZING)
sess = get_sea_text_flow().session_of("s1")
self.assertTrue(sess and sess.phase == "wait_confirm")
self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1])
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"),
store=self.store,
)
sess = get_sea_text_flow().session_of("s1")
self.assertTrue(sess and sess.work_order_no)
def test_mid_clarify_then_attach_is_new_ticket(self) -> None:
@@ -276,6 +292,10 @@ class AttachmentInquiryTests(unittest.TestCase):
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="old-ok", content="确定"),
store=self.store,
)
wo_old = get_sea_text_flow().session_of("s1").work_order_no
handle_attachment_inquiry(
InboundMessage(sender_id="s1", message_id="m2", content="我有个excel要询价"),
@@ -292,6 +312,10 @@ class AttachmentInquiryTests(unittest.TestCase):
"empty": False,
},
)
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="new-ok", content="确定"),
store=self.store,
)
wo_new = get_sea_text_flow().session_of("s1").work_order_no
self.assertNotEqual(wo_old, wo_new)
self.assertIsNotNone(get_sea_text_flow().ledger.get_ticket(work_order_no=wo_old))
@@ -265,6 +265,58 @@ class ContinueTicketTests(unittest.TestCase):
self.assertTrue(any("已记录为「已成交」" in x for x in replies))
self.assertIn(wo, "\n".join(replies))
def test_resume_waiting_lost_reason_saves_sentence(self) -> None:
"""等未成交原因时继续工单,下一句要写入原因,不能再要一遍。"""
ledger = MemoryLedger()
created = ledger.create_ticket(
sender_id="u7", business_line="AIR", facts=COMPLETE_AIR
)
wo = created["work_order_no"]
ledger.upsert_quote(
work_order_no=wo,
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
to_status="已报价",
)
ledger.render_quote(
work_order_no=wo,
business_line="AIR",
facts=COMPLETE_AIR,
quote={"total": "USD 1"},
)
flow = AirTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
self.assertEqual(
flow.resume_historical(sender_id="u7", work_order_no=wo, reply=reply),
"wait_deal",
)
self.assertEqual(
flow.on_button(sender_id="u7", action=copy.BTN_LOST, reply=reply),
"wait_lost_reason",
)
replies.clear()
phase = flow.resume_historical(
sender_id="u7", work_order_no=wo, reply=reply, text=""
)
self.assertEqual(phase, "wait_lost_reason")
self.assertTrue(any("请补充未成交原因" in x for x in replies))
replies.clear()
phase = flow.resume_historical(
sender_id="u7",
work_order_no=wo,
reply=reply,
text="客户不需要了",
)
self.assertEqual(phase, "done")
self.assertTrue(any("已记下未成交原因:客户不需要了" in x for x in replies))
self.assertFalse(any("请补充未成交原因" in x for x in replies))
view = ledger.get_for_agent(work_order_no=wo, sender_id="u7") or {}
self.assertEqual(view.get("lost_reason"), "客户不需要了")
def test_resume_missing_wo_asks(self) -> None:
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
+39
View File
@@ -59,6 +59,37 @@ class OralExtractTests(unittest.TestCase):
out = invoke_shell(payload={"a": 1})
self.assertEqual(out.get("error"), "extract_fields_shell_not_implemented")
def test_city_pair_without_mode_does_not_default_air(self) -> None:
"""没写运输方式时,模型猜的空运不能当成已选。港口和日期仍留下。"""
text = "查一下10月9日郑州-吉隆坡的报价"
guessed = parse_extract_payload(
{
"transport_mode": "AIR",
"fields": {
"起运港": "郑州",
"目的港": "吉隆坡",
"报价日期": "2026-10-09",
},
}
)
snap = extract_inquiry_snapshot(text, b_result=guessed)
self.assertEqual(snap["business_line"], "")
self.assertEqual(snap["facts"].get("起运港"), "郑州")
self.assertEqual(snap["facts"].get("目的港"), "吉隆坡")
self.assertEqual(snap["facts"].get("报价日期"), "2026-10-09")
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
phase = flow.on_text(
sender_id="u-mode",
text=text,
reply=lambda body, extra=None: replies.append(body),
b_result=guessed,
allow_b=False,
)
self.assertEqual(phase, "need_mode")
self.assertTrue(any("告知我询价的运输方式" in x for x in replies))
self.assertFalse(any(x.startswith("运输方式:空运") or "运输方式:空运" in x for x in replies))
def test_oral_snapshot_uses_b_not_regex(self) -> None:
snap = extract_inquiry_snapshot(ORAL, b_result=parse_extract_payload(B_JSON))
self.assertEqual(snap["business_line"], "AIR")
@@ -304,6 +335,14 @@ class OralExtractTests(unittest.TestCase):
self.assertIn("重量(KG):1.5KG", shown)
self.assertNotIn("1.5KGS", shown)
def test_dimension_text_is_volume_without_multiplying(self) -> None:
from agent.schema.field_validate import harvest_oral_measures
facts = harvest_oral_measures("茶叶,1.1*2.1*2.2,11kg,21件", {})
self.assertEqual(facts["体积"], "1.1*2.1*2.2")
self.assertEqual(facts["毛重"], "11kg")
self.assertEqual(facts["件数"], "21件")
def test_oral_units_fill_weight_volume_without_b(self) -> None:
snap = extract_inquiry_snapshot(
"珠海到克拉克空运,茶叶 10件 100KGS 2CBM 托盘"
+19 -1
View File
@@ -134,6 +134,15 @@ class ImageInquiryFlowTests(unittest.TestCase):
sea = get_sea_text_flow()
sess = sea.session_of("s1")
self.assertIsNotNone(sess)
self.assertEqual(sess.phase, "wait_confirm")
self.assertFalse((sess.work_order_no or "").strip())
self.assertTrue(sess.pending_files)
self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1])
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"),
store=self.store,
)
sess = sea.session_of("s1")
self.assertTrue((sess.work_order_no or "").strip())
ticket = sea.ledger.get_ticket(work_order_no=sess.work_order_no)
self.assertTrue(ticket.attachments)
@@ -163,7 +172,8 @@ class ImageInquiryFlowTests(unittest.TestCase):
acks = [c for c in self._bodies() if c == copy.IMAGE_RECOGNIZING]
self.assertEqual(len(acks), 1)
sess = get_sea_text_flow().session_of("s1")
self.assertTrue(sess and sess.work_order_no)
self.assertTrue(sess and sess.phase == "wait_confirm")
self.assertFalse((sess.work_order_no or "").strip())
def test_precede_text_joins_new_ticket(self) -> None:
handle_text_inquiry(
@@ -207,6 +217,10 @@ class ImageInquiryFlowTests(unittest.TestCase):
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="old-ok", content="确定"),
store=self.store,
)
wo_old = get_sea_text_flow().session_of("s1").work_order_no
self.assertTrue(wo_old)
handle_image_inquiry(
@@ -215,6 +229,10 @@ class ImageInquiryFlowTests(unittest.TestCase):
vision_fn=_vision(SEA_ORAL),
download_fn=lambda _mid: (b"x", ""),
)
handle_text_inquiry(
InboundMessage(sender_id="s1", message_id="new-ok", content="确定"),
store=self.store,
)
wo_new = get_sea_text_flow().session_of("s1").work_order_no
self.assertTrue(wo_new)
self.assertNotEqual(wo_old, wo_new)
+12 -1
View File
@@ -257,7 +257,10 @@ class LandDispatchTests(unittest.TestCase):
last = list(self.store._outbox.values())[-1].content
self.assertIn(SYS_EXC_TMS_FALLBACK, last)
def test_sea_still_creates_immediately(self) -> None:
def test_sea_holds_confirm_before_quote(self) -> None:
"""海运私聊字段齐了先出核对卡,回复确定后才查价。"""
from agent.policy import inquiry_copy as copy
msg = InboundMessage(sender_id="s-sea", message_id="m-sea-keep", content="海运询价")
phase = handle_text_inquiry(
msg,
@@ -265,6 +268,14 @@ class LandDispatchTests(unittest.TestCase):
injected_mode="SEA",
store=self.store,
)
self.assertEqual(phase, "wait_confirm")
held = "\n".join(item.content for item in self.store._outbox.values())
self.assertIn(copy.SEA_CONFIRM_TAIL, held)
self.assertNotIn("工单号", held)
phase = handle_text_inquiry(
InboundMessage(sender_id="s-sea", message_id="m-sea-ok", content="确定"),
store=self.store,
)
self.assertEqual(phase, "wait_collab")
def _land_to_quoted(self, sender_id: str) -> None:
@@ -243,6 +243,44 @@ class LandRouteOptionsTests(unittest.TestCase):
valid = text.find("报价有效期:", text.find("费用合计:"))
self.assertGreater(valid, text.find("费用合计:"))
def test_one_leg_detail_does_not_repeat_route_and_card_is_summary(self) -> None:
"""一段陆运:目的站名里已有横杠,明细只写始发→目的;卡片引用条只留合计和项数。"""
from agent.policy import inquiry_copy as copy
dest = "谅山Lạng Sơn-春疆货场Huu Nghi"
quote = {
"source": "TMS",
"source_label": "TMS标准报价",
"currency": "CNY",
"valid_until": "2026-10-31",
"landSegments": [
{
"segmentSequence": 1,
"routeLegSequence": 1,
"origin": "上海",
"destination": dest,
"costPrice": 7500,
"currency": "CNY",
"transitDays": "N+2",
}
],
"fee_lines": [{"name": "运输费", "amount": "7500", "currency": "CNY"}],
}
projected = project_selected_route(quote, group_land_routes(quote)[0])
text = copy.sea_quote_detail_text(
work_order_no="WO202610030035",
quote=projected,
business_line="LAND",
)
self.assertNotIn(f"上海-{dest}", text)
self.assertIn(f"上海 → {dest}", text)
self.assertEqual(text.count(dest), 1)
self.assertIn("运输费:CNY 7500", text)
card = copy.quote_area_safe_text(projected, business_line="LAND")
self.assertEqual(card, "费用合计:CNY 7500\n共1项费用,详见上一条明细")
self.assertNotIn("运输费", card)
self.assertNotIn(dest, card)
def test_land_detail_and_group_use_selected_route_valid_until(self) -> None:
"""选定广西这条后:私聊明细和群 TMS 段同一套正文,不摊开没选的线路。"""
from agent.policy import inquiry_copy as copy
@@ -133,6 +133,29 @@ class SplitTests(unittest.TestCase):
self.assertTrue(outcome.ok)
self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "SEA"])
def test_cargo_before_ordinal_segments_fills_both(self) -> None:
"""段前写一次的品名、尺寸、重量、件数,陆运和空运都要带上。"""
oral = (
"茶叶,1.1*2.1*2.2,11kg,21件\n"
"第一段陆运,北京到上海\n"
"第二段空运,上海到北宁"
)
outcome = split_segments(oral)
self.assertTrue(outcome.ok)
self.assertEqual([seg.index for seg in outcome.segments], [1, 2])
self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "AIR"])
land, air = outcome.segments
self.assertEqual(land.facts.get("品名"), "茶叶")
self.assertEqual(air.facts.get("品名"), "茶叶")
self.assertEqual(air.facts.get("件数"), "21件")
self.assertEqual(air.facts.get("毛重"), "11kg")
self.assertEqual(air.facts.get("体积"), "1.1*2.1*2.2")
_, _, gaps = refresh_segments(outcome.segments)
self.assertNotIn("品名(必填):\n(如:茶叶)", gaps)
self.assertNotIn("重量(KG)(必填):\n(如:", gaps)
self.assertNotIn("体积(CBM)(必填):\n(如:", gaps)
self.assertNotIn("件数(必填):\n(如:", gaps)
def test_group_brief_has_no_blank_under_mentions(self) -> None:
"""拉群摘要 @人后直接接段1,中间不要空行。"""
segs = split_segments(SEA_AIR).segments
+98
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
import json
import os
import sys
import unittest
@@ -618,5 +619,102 @@ class QuoteAdjustTests(unittest.TestCase):
self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1])
class SheetPriceStampTests(unittest.TestCase):
"""报价单读的是线路表和舱位明细,不是只读 fee_lines。"""
def test_sales_adjust_writes_land_route_copies(self) -> None:
src = {
"source": "TMS",
"source_label": "TMS标准报价",
"currency": "CNY",
"total": "CNY 16500",
"fee_lines": [{"name": "运输费", "amount": "16500.00", "currency": "CNY"}],
"feeItems": [{"feeName": "运输费", "amount": 16500.0, "currency": "CNY"}],
"landSegments": [
{"origin": "上海", "destination": "谅山", "costPrice": 16500.0, "currency": "CNY"}
],
"route_rows": [
{
"origin": "上海",
"destination": "谅山",
"costPrice": "16500.00",
"amount": "16500.00",
}
],
"selected_land_route": json.dumps(
{"legs": [{"origin": "上海", "destination": "谅山", "costPrice": 16500.0}]},
ensure_ascii=False,
),
}
got = apply_adjust(
src,
extract_adjust("运输费改为28150", fee_names=["运输费"], allow_model=False),
business_line="LAND",
tms_cost="16500",
tms_sale="16500",
)
self.assertTrue(got.ok)
self.assertIn("28150", str(got.quote["route_rows"][0]["amount"]))
self.assertIn("28150", str(got.quote["route_rows"][0]["costPrice"]))
self.assertIn("28150", str(got.quote["landSegments"][0]["costPrice"]))
selected = json.loads(got.quote["selected_land_route"])
self.assertIn("28150", str(selected["legs"][0]["costPrice"]))
self.assertIn("28150", str(got.quote["feeItems"][0]["amount"]))
def test_product_and_airline_sheet_copies_follow_new_fee(self) -> None:
from agent.policy.sea_group_ops import merge_quote_fees
from agent.schema.quote_adjust import stamp_prices_for_sheet
base = {
"source": "TMS",
"source_label": "TMS标准报价",
"currency": "CNY",
"total": "16500",
"fee_lines": [{"name": "运输费", "amount": "16500", "currency": "CNY"}],
"route_rows": [{"costPrice": "16500", "amount": "16500"}],
"oceanCostItems": [{"name": "海运费", "amount": "100", "currency": "USD"}],
}
merged = merge_quote_fees(
base,
{"fee_lines": [{"name": "运输费", "amount": "18000"}], "source_label": "产品报价"},
)
self.assertIn("18000", str(merged["route_rows"][0]["amount"]))
self.assertEqual(merged["source_label"], "产品报价")
air = stamp_prices_for_sheet(
{
"source_label": "航线报价",
"total": "900",
"currency": "CNY",
"fee_lines": [{"name": "空运费", "amount": "900", "currency": "CNY"}],
"airOptions": [
{
"salePrice": "7918.25",
"departureCharges": [
{"chargeName": "空运费", "unitPrice": "1000", "amount": "1000"}
],
}
],
}
)
self.assertEqual(air["airOptions"][0]["salePrice"], "900")
self.assertIn("900", str(air["airOptions"][0]["departureCharges"][0]["amount"]))
kept = stamp_prices_for_sheet(
{
"source_label": "航线确认TMS",
"total": "200",
"fee_lines": [{"name": "增点费", "amount": "200"}],
"airOptions": [
{
"salePrice": "7918.25",
"departureCharges": [{"chargeName": "增点费", "unitPrice": "100", "amount": "100"}],
}
],
}
)
self.assertEqual(kept["airOptions"][0]["salePrice"], "7918.25")
self.assertEqual(kept["airOptions"][0]["departureCharges"][0]["amount"], "100")
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -71,6 +71,32 @@ class SeaOptionsTest(unittest.TestCase):
example = apply_spoken_sea_trade_reply("(如:门到门、港到港)", {})
self.assertNotIn("贸易条款", example)
def test_door_inside_long_sentence_is_trade_term(self):
"""先报货再选海运:长句里的门到门是贸易条款,港到港仍是运输分类。"""
from agent.schema.sea_options import apply_spoken_sea_trade_reply
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门"
got = apply_spoken_sea_trade_reply(text, {"运输分类": "Port to Port"})
self.assertEqual(got.get("贸易条款"), "门到门")
self.assertEqual(got.get("运输分类"), "Port to Port")
absent = apply_spoken_sea_trade_reply(
"广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port",
{"贸易条款": "门到门", "运输分类": "Port to Port"},
)
self.assertNotIn("贸易条款", absent)
self.assertEqual(absent.get("运输分类"), "Port to Port")
port_term = apply_spoken_sea_trade_reply(
"广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港",
{"运输分类": "Port to Port"},
)
self.assertEqual(port_term.get("贸易条款"), "港到港")
self.assertEqual(port_term.get("运输分类"), "Port to Port")
invented = apply_spoken_sea_trade_reply(
"广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port",
{"贸易条款": "港到港"},
)
self.assertNotIn("贸易条款", invented)
def test_wo012_trade_port_class_other(self):
"""条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。"""
text = (
+200 -26
View File
@@ -46,6 +46,14 @@ COMPLETE_SEA = {
}
def drive_sea(flow, **kwargs):
"""字段齐时先出核对卡;要看到查价结果的旧用例再自动回一句确定。"""
phase = flow.on_text(**kwargs)
if phase == "wait_confirm":
phase = flow.on_text(sender_id=kwargs["sender_id"], text="确定", reply=kwargs["reply"])
return phase
class SeaCollabContractTests(unittest.TestCase):
def test_collab_no_longer_asks_trade_terms(self):
self.assertNotIn("贸易条款", SEA_COLLAB_FIELDS)
@@ -127,6 +135,80 @@ class SeaTextInquiryTests(unittest.TestCase):
self.replies.append(text)
self.extras.append(dict(extra) if extra else {})
def test_complete_fields_hold_confirm_before_tms(self) -> None:
"""字段齐了只出核对卡,不建单、不查价。"""
phase = self.flow.on_text(
sender_id="s-hold",
text="海运完整",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_confirm")
card = self.replies[-1]
self.assertIn(copy.SEA_CONFIRM_TAIL, card)
self.assertIn("运输方式(必填):海运", card)
self.assertIn("货物数量(必填):20吨", card)
self.assertIn("贸易条款(必填):FOB", card)
self.assertNotIn("工单号", card)
self.assertNotIn("正在查询TMS报价", card)
sess = self.flow.session_of("s-hold")
self.assertEqual(sess.phase, "wait_confirm")
self.assertFalse((sess.work_order_no or "").strip())
self.assertEqual(self.ledger._tickets, {})
def test_confirm_phrases_then_query_tms(self) -> None:
"""确定、确认、查价、可以查了 都建单并查价。"""
for word in ("确定", "确认", "查价", "可以查了"):
with self.subTest(word=word):
self.replies.clear()
sender = f"s-go-{word}"
held = self.flow.on_text(
sender_id=sender,
text="海运完整",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(held, "wait_confirm")
self.replies.clear()
phase = self.flow.on_text(sender_id=sender, text=word, reply=self._reply)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("正在查询TMS报价", joined)
self.assertIn("工单号:", joined)
sess = self.flow.session_of(sender)
self.assertTrue((sess.work_order_no or "").startswith("WO"))
def test_resend_while_waiting_merges_and_does_not_quote(self) -> None:
"""核对卡出来后改一处,并进原字段再出卡,仍不建单。"""
phase = self.flow.on_text(
sender_id="s-fix",
text="海运完整",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_confirm")
self.replies.clear()
fixed = dict(COMPLETE_SEA)
fixed["目的港"] = "纽约"
phase = self.flow.on_text(
sender_id="s-fix",
text="目的港改成纽约",
reply=self._reply,
injected_facts=fixed,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_confirm")
self.assertIn("目的港(必填):纽约", self.replies[-1])
self.assertIn(copy.SEA_CONFIRM_TAIL, self.replies[-1])
sess = self.flow.session_of("s-fix")
self.assertEqual(sess.facts.get("目的港"), "纽约")
self.assertEqual(sess.facts.get("起运港"), "上海")
self.assertFalse((sess.work_order_no or "").strip())
self.assertEqual(self.ledger._tickets, {})
def test_sea_pieces_fill_cargo_qty(self) -> None:
facts = {
"起运港": "上海",
@@ -287,7 +369,7 @@ class SeaTextInquiryTests(unittest.TestCase):
def test_incomplete_without_trade_terms_does_not_create_ticket(self) -> None:
facts = dict(COMPLETE_SEA)
facts.pop("贸易条款")
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s-miss",
text="海运缺条款",
reply=self._reply,
@@ -318,7 +400,7 @@ class SeaTextInquiryTests(unittest.TestCase):
facts=leftover,
)
)
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s-door",
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件",
reply=self._reply,
@@ -335,7 +417,7 @@ class SeaTextInquiryTests(unittest.TestCase):
"""补问后只回门到门,要记成贸易条款,不能回参数提取不全。"""
first = dict(COMPLETE_SEA)
first.pop("贸易条款")
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s-door-reply",
text="先缺条款",
reply=self._reply,
@@ -344,7 +426,7 @@ class SeaTextInquiryTests(unittest.TestCase):
)
self.assertEqual(phase, "clarify")
self.replies.clear()
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s-door-reply",
text="门到门",
reply=self._reply,
@@ -354,10 +436,92 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("贸易条款:门到门", joined)
self.assertNotIn("参数提取不全", joined)
def test_door_in_sentence_kept_after_choosing_sea(self) -> None:
"""没写海运、句中有门到门,回「2」后贸易条款要带上,不能再追问。"""
from agent.channel.queue import MemoryMessageStore
from agent.policy.air_text_flow import reset_air_text_flow_for_test
from agent.policy.land_text_flow import reset_land_text_flow_for_test
from agent.policy.sea_text_flow import get_sea_text_flow
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
store = MemoryMessageStore()
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门"
facts = {
"起运港": "广州港",
"目的港": "巴生港",
"品名": "机器设备",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"运输分类": "Port to Port",
}
phase = handle_text_inquiry(
InboundMessage(sender_id="s-door-mode", message_id="m-door-1", content=text),
injected_facts=facts,
store=store,
)
self.assertEqual(phase, "need_mode")
phase = handle_text_inquiry(
InboundMessage(sender_id="s-door-mode", message_id="m-door-2", content="2"),
store=store,
)
body = "\n".join(item.content for item in store._outbox.values())
sess = get_sea_text_flow().session_of("s-door-mode")
self.assertEqual(sess.facts.get("贸易条款"), "门到门")
self.assertEqual(sess.facts.get("运输分类"), "Port to Port")
self.assertIn("贸易条款(必填):门到门", body)
self.assertIn(copy.SEA_CONFIRM_TAIL, body)
self.assertEqual(phase, "wait_confirm")
self.assertNotIn("1.贸易条款(必填)", body)
self.assertNotEqual(phase, "need_mode")
def test_port_to_port_phrase_kept_after_choosing_sea(self) -> None:
"""没写海运、句中有港到港,回「2」后贸易条款是港到港,运输分类仍是 Port to Port。"""
from agent.channel.queue import MemoryMessageStore
from agent.policy.air_text_flow import reset_air_text_flow_for_test
from agent.policy.land_text_flow import reset_land_text_flow_for_test
from agent.policy.sea_text_flow import get_sea_text_flow
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
store = MemoryMessageStore()
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港"
facts = {
"起运港": "广州港",
"目的港": "巴生港",
"品名": "机器设备",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"运输分类": "Port to Port",
}
phase = handle_text_inquiry(
InboundMessage(sender_id="s-port-mode", message_id="m-port-1", content=text),
injected_facts=facts,
store=store,
)
self.assertEqual(phase, "need_mode")
phase = handle_text_inquiry(
InboundMessage(sender_id="s-port-mode", message_id="m-port-2", content="2"),
store=store,
)
body = "\n".join(item.content for item in store._outbox.values())
sess = get_sea_text_flow().session_of("s-port-mode")
self.assertEqual(sess.facts.get("贸易条款"), "港到港")
self.assertEqual(sess.facts.get("运输分类"), "Port to Port")
self.assertIn("贸易条款(必填):港到港", body)
self.assertIn(copy.SEA_CONFIRM_TAIL, body)
self.assertEqual(phase, "wait_confirm")
self.assertNotIn("1.贸易条款(必填)", body)
self.assertNotEqual(phase, "need_mode")
def test_harvest_class_from_reply_then_quotes(self) -> None:
first = dict(COMPLETE_SEA)
first.pop("运输分类")
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s-class",
text="先缺分类",
reply=self._reply,
@@ -366,7 +530,7 @@ class SeaTextInquiryTests(unittest.TestCase):
)
self.assertEqual(phase, "clarify")
self.replies.clear()
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s-class",
text="Port to Port",
reply=self._reply,
@@ -378,7 +542,7 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("贸易条款:FOB", joined)
def test_complete_sea_inquiry_card_uses_ten_fields(self) -> None:
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s1",
text="海运完整",
reply=self._reply,
@@ -404,7 +568,7 @@ class SeaTextInquiryTests(unittest.TestCase):
def test_new_round_after_ticket_does_not_reuse_old_facts(self) -> None:
"""上一单已出号,新一轮不能带上旧贸易条款。"""
first = self.flow.on_text(
first = drive_sea(self.flow,
sender_id="s-round",
text="海运第一轮",
reply=self._reply,
@@ -424,7 +588,7 @@ class SeaTextInquiryTests(unittest.TestCase):
"箱型箱量": "40HQ*1",
"运输分类": "Port to Port",
}
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s-round",
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port",
reply=self._reply,
@@ -457,7 +621,7 @@ class SeaTextInquiryTests(unittest.TestCase):
flow=self.flow,
store=store,
)
self.assertEqual(phase, "wait_collab")
self.assertEqual(phase, "wait_confirm")
self.assertNotEqual(phase, "sea_deferred")
@@ -569,9 +733,19 @@ class SeaTmsCardTests(unittest.TestCase):
self.assertNotIn("时效", text)
self.assertNotIn("报价有效期", text)
card_text = copy.quote_area_safe_text(long_quote, business_line="SEA")
self.assertIn("费用合计:CNY 2287.65", card_text)
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
self.assertLessEqual(len(card_text), 80)
self.assertEqual(
card_text,
"费用合计:CNY 2287.65\n共7项费用,详见上一条明细",
)
short_quote = {
"total": "100",
"currency": "CNY",
"fee_lines": [{"name": "海运费", "amount": "100", "currency": "CNY"}],
}
self.assertEqual(
copy.quote_area_safe_text(short_quote, business_line="SEA"),
"费用合计:CNY 100\n共1项费用,详见上一条明细",
)
payload = copy.sea_tms_hit_wecom_payload(
work_order_no="WO202609150030", quote=long_quote
)
@@ -614,7 +788,7 @@ class SeaTmsCardTests(unittest.TestCase):
self.assertIn("card", steps)
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s1",
text="海运有价",
reply=self._reply,
@@ -639,7 +813,7 @@ class SeaTmsCardTests(unittest.TestCase):
def test_sea_miss_one_card_with_pull_button(self) -> None:
facts = dict(COMPLETE_SEA)
facts["目的港"] = "无价港"
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s1",
text="海运无价",
reply=self._reply,
@@ -664,7 +838,7 @@ class SeaTmsCardTests(unittest.TestCase):
def test_sea_tech_fail_is_not_miss(self) -> None:
facts = dict(COMPLETE_SEA)
facts["tms_force"] = "tech"
phase = self.flow.on_text(
phase = drive_sea(self.flow,
sender_id="s1",
text="海运技术失败",
reply=self._reply,
@@ -708,7 +882,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
return ""
def _quote_first(self, sender: str = "s1") -> None:
self.flow.on_text(
drive_sea(self.flow,
sender_id=sender,
text="海运有价",
reply=self._reply,
@@ -820,7 +994,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
)
facts = dict(COMPLETE_SEA)
facts["是否含电"] = "否"
self.flow.on_text(
drive_sea(self.flow,
sender_id="s1",
text="海运不含电",
reply=self._reply,
@@ -857,7 +1031,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
)
with patch("agent.policy.sea_text_flow._default_group_client", return_value=live):
flow = SeaTextInquiryFlow(self.ledger, group_client=None)
flow.on_text(
drive_sea(flow,
sender_id="s9",
text="海运有价",
reply=self._reply,
@@ -964,7 +1138,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
)
facts = dict(COMPLETE_SEA)
facts["目的港"] = "无价港"
self.flow.on_text(
drive_sea(self.flow,
sender_id="s1",
text="海运无价",
reply=self._reply,
@@ -1018,7 +1192,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
}
]
)
self.flow.on_text(
drive_sea(self.flow,
sender_id="sales1",
text="海运有价",
reply=self._reply,
@@ -1074,7 +1248,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
}
]
)
self.flow.on_text(
drive_sea(self.flow,
sender_id="sales1",
text="海运有价",
reply=self._reply,
@@ -1258,7 +1432,7 @@ class SeaContinueTests(unittest.TestCase):
from agent.handlers.continue_thread import handle_continue_thread
from agent.policy.air_text_flow import AirTextInquiryFlow
self.flow.on_text(
drive_sea(self.flow,
sender_id="c1",
text="海运有价",
reply=self._reply,
@@ -1358,7 +1532,7 @@ class SeaContinueTests(unittest.TestCase):
from agent.handlers.continue_thread import handle_continue_thread
from agent.policy.air_text_flow import AirTextInquiryFlow
self.flow.on_text(
drive_sea(self.flow,
sender_id="c3",
text="海运有价",
reply=self._reply,
@@ -1590,7 +1764,7 @@ class SeaGroupOpsTests(unittest.TestCase):
self.replies.append(text)
def _open_group(self) -> tuple[str, str]:
self.flow.on_text(
drive_sea(self.flow,
sender_id="sales1",
text="海运有价",
reply=self._reply,
@@ -2175,7 +2349,7 @@ class SeaGroupOpsTests(unittest.TestCase):
"""TMS 无价时产品文字报价:摘要 TMS 仍是暂无,人工报价两行齐全。"""
facts = dict(COMPLETE_SEA)
facts["目的港"] = "无价港"
self.flow.on_text(
drive_sea(self.flow,
sender_id="sales1",
text="海运无价",
reply=self._reply,