员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from agent.policy.deal_followup import (
|
||||
SHANGHAI,
|
||||
add_workday_hours,
|
||||
air_route_keys,
|
||||
air_staff_route_matches,
|
||||
is_workday,
|
||||
leader_matches,
|
||||
next_followup_at,
|
||||
@@ -193,5 +194,37 @@ class TestLeaderMatch(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestAirStaffRouteMatches(unittest.TestCase):
|
||||
"""群 @ 航线:按负责线路过滤,不要求 isLeader。"""
|
||||
|
||||
def test_ehu_ord_pair_and_all_line(self) -> None:
|
||||
facts = {"起运港": "EHU", "目的港": "ORD"}
|
||||
self.assertTrue(air_staff_route_matches(routes="EHU-ORD", facts=facts))
|
||||
self.assertTrue(air_staff_route_matches(routes="空运全线", facts=facts))
|
||||
self.assertFalse(air_staff_route_matches(routes="CGO-KUL,SZX-KUL", facts=facts))
|
||||
self.assertFalse(air_staff_route_matches(routes="HKG-KUL,NKG-KUL", facts=facts))
|
||||
|
||||
def test_dest_only_matches_pair_suffix(self) -> None:
|
||||
facts = {"目的港": "KUL"}
|
||||
self.assertTrue(air_staff_route_matches(routes="CGO-KUL,SZX-KUL", facts=facts))
|
||||
self.assertTrue(air_staff_route_matches(routes="HKG-KUL,NKG-KUL", facts=facts))
|
||||
self.assertFalse(air_staff_route_matches(routes="EHU-ORD", facts=facts))
|
||||
self.assertFalse(air_staff_route_matches(routes="CGO-BLR,CGO-MAA", facts=facts))
|
||||
|
||||
def test_non_leader_still_matches(self) -> None:
|
||||
# 群 @ 与成交推负责人不同:非负责人只要线路对也要 @
|
||||
facts = {"起运港": "EHU", "目的港": "ORD"}
|
||||
self.assertTrue(air_staff_route_matches(routes="EHU-ORD", facts=facts))
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="EHU-ORD",
|
||||
is_leader=False,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -364,6 +364,53 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高")
|
||||
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
|
||||
|
||||
def test_orphan_lost_wait_clears_when_ticket_missing(self) -> None:
|
||||
"""清数后主账无单:等原因书签必须丢掉,不能死循环「请再发一句」。"""
|
||||
from agent.handlers.text_inquiry import _flow_waiting_lost_reason
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||||
|
||||
book = MemoryLedger()
|
||||
air = AirTextInquiryFlow(book)
|
||||
sea = SeaTextInquiryFlow(book)
|
||||
land = LandTextInquiryFlow(book)
|
||||
ghost = "WO202610090041"
|
||||
land._save(
|
||||
FlowSession(
|
||||
sender_id="YangHuiMing",
|
||||
thread_id="YangHuiMing",
|
||||
phase="wait_lost_reason",
|
||||
business_line="LAND",
|
||||
work_order_no=ghost,
|
||||
status="未成交",
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(land.session_of("YangHuiMing"))
|
||||
# 路由前应 scrub 孤儿,不再交给陆运等原因
|
||||
self.assertIsNone(_flow_waiting_lost_reason("YangHuiMing", sea, air, land))
|
||||
self.assertIsNone(land.session_of("YangHuiMing"))
|
||||
|
||||
# 若仍走到 capture:也应清书签并提示可新询价
|
||||
land._save(
|
||||
FlowSession(
|
||||
sender_id="YangHuiMing",
|
||||
thread_id="YangHuiMing",
|
||||
phase="wait_lost_reason",
|
||||
business_line="LAND",
|
||||
work_order_no=ghost,
|
||||
status="未成交",
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
phase = land._capture_lost_reason(
|
||||
land.session_of("YangHuiMing"),
|
||||
"客户取消订单",
|
||||
lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "orphan_cleared")
|
||||
self.assertTrue(any("已不在系统中" in t for t in replies))
|
||||
self.assertIsNone(land.session_of("YangHuiMing"))
|
||||
|
||||
def test_sea_private_next_text_is_lost_reason(self) -> None:
|
||||
flow = SeaTextInquiryFlow(self.ledger)
|
||||
flow._save(
|
||||
|
||||
@@ -300,10 +300,22 @@ class TestPoll(unittest.TestCase):
|
||||
"name": "吴季林",
|
||||
"roleCode": "air",
|
||||
"status": "active",
|
||||
"routes": "空运全线",
|
||||
},
|
||||
{
|
||||
"wecomId": "air2",
|
||||
"name": "KUL专员",
|
||||
"roleCode": "air",
|
||||
"status": "active",
|
||||
"routes": "CGO-KUL,SZX-KUL",
|
||||
},
|
||||
]
|
||||
)
|
||||
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"})
|
||||
created = book.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="AIR",
|
||||
facts={"起运港": "EHU", "目的港": "ORD"},
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="air_quote")
|
||||
@@ -331,6 +343,7 @@ class TestPoll(unittest.TestCase):
|
||||
f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||
)
|
||||
self.assertNotIn("相关人员", groups[0]["content"])
|
||||
self.assertNotIn("KUL专员", groups[0]["content"])
|
||||
|
||||
def test_sales_timeout_mentions_sales_not_airline(self) -> None:
|
||||
"""销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。"""
|
||||
|
||||
@@ -721,5 +721,56 @@ class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
|
||||
self.assertNotIn("友通达", blob)
|
||||
|
||||
|
||||
class QuoteIdNotOriginTests(unittest.TestCase):
|
||||
"""识图把报价编号错贴到起运地时必须丢掉,不能当港口查价。"""
|
||||
|
||||
def test_quote_no_labeled_as_origin_dropped(self) -> None:
|
||||
text = (
|
||||
"报价编号:QT202610220088\n"
|
||||
"起运地:QT202610220088\n"
|
||||
"目的地:吉隆坡\n"
|
||||
"品名:茶叶\n"
|
||||
"件数:21件\n"
|
||||
"毛重:11KG\n"
|
||||
"体积:1.1*2.1*2.2\n"
|
||||
"包装类型:托盘\n"
|
||||
"运输方式:空运"
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
|
||||
self.assertNotIn("起运港", facts)
|
||||
self.assertEqual(facts.get("目的港"), "吉隆坡")
|
||||
|
||||
def test_work_order_as_origin_dropped(self) -> None:
|
||||
text = (
|
||||
"起运地:WO202610220001\n"
|
||||
"目的地:吉隆坡\n"
|
||||
"品名:茶叶\n"
|
||||
"件数:10件\n"
|
||||
"毛重:11KG\n"
|
||||
"体积:1CBM\n"
|
||||
"包装方式:托盘\n"
|
||||
"空运"
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
|
||||
self.assertNotIn("起运港", facts)
|
||||
self.assertEqual(facts.get("目的港"), "吉隆坡")
|
||||
|
||||
def test_real_city_origin_kept(self) -> None:
|
||||
text = (
|
||||
"报价编号:QT202610220088\n"
|
||||
"起运地:南京\n"
|
||||
"目的地:吉隆坡\n"
|
||||
"品名:茶叶\n"
|
||||
"件数:10件\n"
|
||||
"毛重:11KG\n"
|
||||
"体积:1CBM\n"
|
||||
"包装方式:托盘\n"
|
||||
"空运"
|
||||
)
|
||||
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
|
||||
self.assertEqual(facts.get("起运港"), "南京")
|
||||
self.assertEqual(facts.get("目的港"), "吉隆坡")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -41,10 +41,12 @@ class LandCollabFieldsTest(unittest.TestCase):
|
||||
self.assertIn("HS编码", keys)
|
||||
self.assertNotIn("车型/数量", keys)
|
||||
|
||||
def test_hk_ftl_has_vehicle(self):
|
||||
def test_hk_ftl_vehicle_is_inquiry_not_collab(self):
|
||||
"""中港整车车型/数量已改到询价必填,协同不再要。"""
|
||||
keys = land_collab_fields("中港整车", "中港/中亚/中欧")
|
||||
self.assertIn("车型/数量", keys)
|
||||
self.assertNotIn("车型/数量", keys)
|
||||
self.assertEqual(keys[0], "客户名称")
|
||||
self.assertIn("HS编码", keys)
|
||||
|
||||
def test_unlisted_pair_empty(self):
|
||||
self.assertEqual(land_collab_fields("国内运输拼车", "东南亚"), ())
|
||||
|
||||
@@ -53,11 +53,11 @@ class LandCopyTest(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("通关口岸(非必填):", text)
|
||||
|
||||
def test_hk_lcl_shows_vehicle_not_load_field(self):
|
||||
def test_hk_lcl_hides_vehicle_not_load_field(self):
|
||||
text = copy.land_confirm_card(
|
||||
{
|
||||
"运输类型": "中港零担/集拼",
|
||||
"线路类别": "东南亚",
|
||||
"线路类别": "中港/中亚/中欧",
|
||||
"运输分类": "急件",
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
@@ -67,24 +67,25 @@ class LandCopyTest(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
self.assertIn("运输类型(必填):中港零担/集拼", text)
|
||||
self.assertIn("车型/数量(必填):9米6/1", text)
|
||||
self.assertNotIn("车型/数量", text)
|
||||
self.assertNotIn("中港装载方式", text)
|
||||
self.assertIn("通关口岸(非必填)", text)
|
||||
|
||||
def test_hk_full_truck_hides_vehicle(self):
|
||||
def test_hk_full_truck_shows_vehicle(self):
|
||||
text = copy.land_confirm_card(
|
||||
{
|
||||
"运输类型": "中港整车",
|
||||
"线路类别": "东南亚",
|
||||
"线路类别": "中港/中亚/中欧",
|
||||
"运输分类": "急件",
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"车型/数量": "9米6/1",
|
||||
"报价日期": "2026-09-17",
|
||||
}
|
||||
)
|
||||
self.assertIn("运输类型(必填):中港整车", text)
|
||||
self.assertNotIn("车型/数量", text)
|
||||
self.assertIn("车型/数量(必填):9米6/1", text)
|
||||
self.assertNotIn("中港装载方式", text)
|
||||
|
||||
def test_domestic_ftl_one_field(self):
|
||||
|
||||
@@ -31,24 +31,25 @@ class LandFieldValidateTest(unittest.TestCase):
|
||||
self.assertFalse(got["ok"])
|
||||
self.assertIn("体积", got["missing"])
|
||||
|
||||
def test_hk_lcl_still_needs_vehicle(self):
|
||||
def test_hk_lcl_skips_vehicle(self):
|
||||
"""中港零担/集拼:询价不要车型/数量。"""
|
||||
got = validate_required_fields(
|
||||
facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"体积": "10CBM",
|
||||
"毛重": "50kg",
|
||||
},
|
||||
business_line="LAND",
|
||||
land_subtype="中港零担/集拼",
|
||||
)
|
||||
self.assertIn("车型/数量", got["missing"])
|
||||
self.assertNotIn("数量", got["missing"])
|
||||
self.assertNotIn("车型", got["missing"])
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertNotIn("车型/数量", got["missing"])
|
||||
self.assertNotIn("体积", got["missing"])
|
||||
self.assertNotIn("毛重", got["missing"])
|
||||
|
||||
def test_hk_full_truck_skips_vehicle(self):
|
||||
got = validate_required_fields(
|
||||
def test_hk_full_truck_requires_vehicle(self):
|
||||
"""中港整车:询价必填车型/数量。"""
|
||||
missing = validate_required_fields(
|
||||
facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
@@ -57,8 +58,20 @@ class LandFieldValidateTest(unittest.TestCase):
|
||||
business_line="LAND",
|
||||
land_subtype="中港整车",
|
||||
)
|
||||
self.assertFalse(missing["ok"])
|
||||
self.assertIn("车型/数量", missing["missing"])
|
||||
got = validate_required_fields(
|
||||
facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"车型/数量": "9米6/1",
|
||||
},
|
||||
business_line="LAND",
|
||||
land_subtype="中港整车",
|
||||
)
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertNotIn("车型/数量", got["missing"])
|
||||
self.assertEqual(got["facts"]["车型/数量"], "9米6/1")
|
||||
|
||||
def test_domestic_ftl_one_field(self):
|
||||
got = validate_required_fields(
|
||||
|
||||
@@ -112,25 +112,22 @@ class LandTextInquiryFrontTests(unittest.TestCase):
|
||||
self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车")
|
||||
self.assertEqual(sess.facts.get("线路类别"), "东南亚")
|
||||
|
||||
def test_hk_lcl_asks_vehicle(self) -> None:
|
||||
def test_hk_lcl_skips_vehicle(self) -> None:
|
||||
"""中港零担/集拼:不补问车型/数量,齐字段后直接出核对卡。"""
|
||||
phase = self.flow.on_text(
|
||||
sender_id="u1",
|
||||
text="中港零担/集拼 东南亚 急件",
|
||||
text="中港零担/集拼 中港/中亚/中欧 急件",
|
||||
reply=self._reply,
|
||||
injected_facts={
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"体积": "10CBM",
|
||||
"毛重": "50kg",
|
||||
},
|
||||
injected_mode="LAND",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertIn("车型/数量(必填)", self._joined())
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertNotIn("车型/数量", self._joined())
|
||||
self.assertIn("通关口岸(非必填)", self._joined())
|
||||
self.assertNotIn("\n数量:", self._joined())
|
||||
self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined())
|
||||
self.assertEqual(self.ledger.create_calls, 0)
|
||||
|
||||
def test_hk_full_truck_skips_vehicle(self) -> None:
|
||||
|
||||
@@ -22,6 +22,7 @@ from agent.schema.quote_adjust import (
|
||||
apply_adjust,
|
||||
effective_quote,
|
||||
extract_adjust,
|
||||
parse_money,
|
||||
private_session_can_adjust,
|
||||
total_price_label,
|
||||
)
|
||||
@@ -48,6 +49,151 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
self.assertEqual(str(got.quote["total"]), "1300.00")
|
||||
self.assertFalse(got.currency_hint)
|
||||
|
||||
def test_increase_to_percent_is_set_not_up(self) -> None:
|
||||
"""「运输费用增加到110%」= 原价×1.1,不是上调 110%(×2.1)。"""
|
||||
src = _quote(("运输费", "CNY 7500.00", "CNY"), total="CNY 7500.00")
|
||||
ext = extract_adjust(
|
||||
"运输费用增加到110%",
|
||||
fee_names=["运输费"],
|
||||
allow_model=False,
|
||||
)
|
||||
self.assertTrue(ext.is_adjust)
|
||||
self.assertEqual(len(ext.items), 1)
|
||||
self.assertEqual(ext.items[0].kind, "percent")
|
||||
self.assertEqual(ext.items[0].direction, "set")
|
||||
self.assertEqual(ext.items[0].value, 110.0)
|
||||
got = apply_adjust(src, ext, business_line="LAND", tms_cost="7500")
|
||||
self.assertTrue(got.ok)
|
||||
self.assertEqual(got.quote["fee_rows"][0]["amount"], "CNY 8250.00")
|
||||
self.assertEqual(str(got.quote["total"]), "CNY 8250.00")
|
||||
self.assertIn("调到 110%", got.applied[0]["how"])
|
||||
|
||||
def test_air_unit_price_percent_keeps_per_kg_not_line_total(self) -> None:
|
||||
"""空运「单价增加到110%」:单价 19→20.9,行金额=20.9×162;禁止把合计写进单价。"""
|
||||
src = {
|
||||
"currency": "CNY",
|
||||
"total": "3438",
|
||||
"fee_rows": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"amount": "3078",
|
||||
"currency": "CNY",
|
||||
"unit": "KG",
|
||||
"unit_price": "19",
|
||||
"quantity": "162",
|
||||
},
|
||||
{
|
||||
"name": "人工费",
|
||||
"amount": "200",
|
||||
"currency": "CNY",
|
||||
"unit": "人",
|
||||
"unit_price": "200",
|
||||
"quantity": "1",
|
||||
},
|
||||
{
|
||||
"name": "打板费",
|
||||
"amount": "160",
|
||||
"currency": "CNY",
|
||||
"unit": "个",
|
||||
"unit_price": "160",
|
||||
"quantity": "1",
|
||||
},
|
||||
],
|
||||
}
|
||||
ext = extract_adjust(
|
||||
"空运费单价增加到110%/KG",
|
||||
fee_names=["空运费", "人工费", "打板费"],
|
||||
allow_model=False,
|
||||
)
|
||||
got = apply_adjust(src, ext, business_line="AIR")
|
||||
self.assertTrue(got.ok)
|
||||
air = next(r for r in got.quote["fee_rows"] if r["name"] == "空运费")
|
||||
self.assertEqual(air["amount"], "3385.80")
|
||||
self.assertEqual(str(air.get("unit_price") or ""), "20.90")
|
||||
self.assertNotEqual(str(air.get("unit_price") or ""), "3385.80")
|
||||
|
||||
def test_air_unit_percent_uses_chargeable_weight_when_qty_missing(self) -> None:
|
||||
"""费用行无 quantity 时,用工单计费重:单价15→16.5,禁止把行合计写进单价。"""
|
||||
src = {
|
||||
"currency": "CNY",
|
||||
"total": "38757.75",
|
||||
"chargeableWeightKg": "2583.85",
|
||||
"fee_rows": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"amount": "38757.75",
|
||||
"currency": "CNY",
|
||||
"unit": "KG",
|
||||
"unit_price": "15",
|
||||
"unitPrice": "15",
|
||||
}
|
||||
],
|
||||
"selected_air_option": {
|
||||
"chargeableWeightKg": 2583.85,
|
||||
"airlineCode": "TH",
|
||||
"feeItems": [
|
||||
{
|
||||
"chargeName": "空运费",
|
||||
"unitPrice": 15,
|
||||
"amount": 38757.75,
|
||||
"unit": "KG",
|
||||
"currency": "CNY",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
ext = extract_adjust(
|
||||
"空运费单价增加到110%/KG",
|
||||
fee_names=["空运费"],
|
||||
allow_model=False,
|
||||
)
|
||||
got = apply_adjust(src, ext, business_line="AIR")
|
||||
self.assertTrue(got.ok)
|
||||
air = next(r for r in got.quote["fee_rows"] if r["name"] == "空运费")
|
||||
self.assertEqual(str(air.get("unit_price") or ""), "16.50")
|
||||
self.assertNotEqual(str(air.get("unit_price") or ""), "42633.53")
|
||||
amt, _ = parse_money(air.get("amount"))
|
||||
self.assertAlmostEqual(float(amt or 0), 42633.525, places=2)
|
||||
|
||||
def test_model_up_coerced_when_text_says_to_percent(self) -> None:
|
||||
"""模型误抽成 up 时,按原话「增加到X%」纠正为 set。"""
|
||||
src = _quote(("运输费", "7500", "CNY"), total="7500")
|
||||
injected = {
|
||||
"is_adjust": True,
|
||||
"wants_total": False,
|
||||
"items": [{"fee_name": "运输费", "kind": "percent", "direction": "up", "value": 110}],
|
||||
}
|
||||
ext = extract_adjust(
|
||||
"运输费用增加到110%",
|
||||
fee_names=["运输费"],
|
||||
injected=injected,
|
||||
allow_model=False,
|
||||
)
|
||||
self.assertEqual(ext.items[0].direction, "set")
|
||||
got = apply_adjust(src, ext, business_line="LAND")
|
||||
self.assertEqual(got.quote["fee_rows"][0]["amount"], "8250.00")
|
||||
|
||||
def test_to_percent_phrase_variants(self) -> None:
|
||||
"""调整到/涨到/升到/增至/变为/上浮到 X% 一律按改到原价比例。"""
|
||||
src = _quote(("运输费", "CNY 7500.00", "CNY"), total="CNY 7500.00")
|
||||
phrases = (
|
||||
"运输费调整到110%",
|
||||
"运输费涨到110%",
|
||||
"运输费升到110%",
|
||||
"运输费增至110%",
|
||||
"运输费变为110%",
|
||||
"运输费上浮到110%",
|
||||
)
|
||||
for text in phrases:
|
||||
with self.subTest(text=text):
|
||||
ext = extract_adjust(text, fee_names=["运输费"], allow_model=False)
|
||||
self.assertTrue(ext.is_adjust, text)
|
||||
self.assertEqual(ext.items[0].kind, "percent", text)
|
||||
self.assertEqual(ext.items[0].direction, "set", text)
|
||||
got = apply_adjust(src, ext, business_line="LAND")
|
||||
self.assertTrue(got.ok, text)
|
||||
self.assertEqual(got.quote["fee_rows"][0]["amount"], "CNY 8250.00", text)
|
||||
|
||||
def test_percent_down_and_target_in_one_sentence(self) -> None:
|
||||
src = _quote(("提货费", "1000", "CNY"), ("送货费", "200", "CNY"), total="1200")
|
||||
ext = extract_adjust("提货费减 5%,送货费提升到 180 元", fee_names=["提货费", "送货费"])
|
||||
@@ -573,6 +719,87 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
self.assertTrue(copy.quote_is_tms(sess.tms_quote))
|
||||
self.assertFalse(copy.quote_is_tms(sess.quote))
|
||||
|
||||
def test_private_air_adjust_emits_facts_so_cargo_not_blank(self) -> None:
|
||||
"""空运私聊调价出卡必须带 sess.facts,否则起运地/品名会整段变「-」。"""
|
||||
from agent.policy.quote_adjust_ops import try_private_adjust
|
||||
|
||||
class _Sess:
|
||||
status = "已报价"
|
||||
phase = "wait_collab"
|
||||
work_order_no = "WO202610280072"
|
||||
business_line = "AIR"
|
||||
collab_chat_id = ""
|
||||
wait_version = 1
|
||||
first_or_same = "first"
|
||||
history_work_order_no = ""
|
||||
facts = {
|
||||
"起运地": "南京",
|
||||
"目的地": "吉隆坡",
|
||||
"品名": "普货",
|
||||
"件数": "15",
|
||||
"毛重": "2403",
|
||||
"体积": "15.5",
|
||||
"包装类型": "托盘",
|
||||
}
|
||||
quote = {
|
||||
"currency": "CNY",
|
||||
"total": "38757.75",
|
||||
"chargeableWeightKg": "2583.85",
|
||||
"fee_rows": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"amount": "38757.75",
|
||||
"currency": "CNY",
|
||||
"unit": "KG",
|
||||
"unit_price": "15",
|
||||
}
|
||||
],
|
||||
}
|
||||
tms_quote = {}
|
||||
|
||||
class _Flow:
|
||||
def __init__(self) -> None:
|
||||
self.emitted = None
|
||||
|
||||
class _Ledger:
|
||||
def upsert_product_quote(self, **kwargs):
|
||||
return {"ok": True}
|
||||
|
||||
self.ledger = _Ledger()
|
||||
|
||||
def _save(self, sess) -> None:
|
||||
return None
|
||||
|
||||
def _emit_air_hit(self, reply, **kwargs) -> None:
|
||||
self.emitted = kwargs
|
||||
|
||||
sess = _Sess()
|
||||
flow = _Flow()
|
||||
out = try_private_adjust(
|
||||
flow,
|
||||
sess,
|
||||
"空运费单价增加到110%/KG",
|
||||
lambda *_a, **_k: None,
|
||||
injected_adjust={
|
||||
"is_adjust": True,
|
||||
"items": [
|
||||
{
|
||||
"fee_name": "空运费",
|
||||
"kind": "percent",
|
||||
"direction": "set",
|
||||
"value": 110,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(out, "wait_collab")
|
||||
self.assertIsNotNone(flow.emitted)
|
||||
facts = dict(flow.emitted.get("facts") or {})
|
||||
self.assertEqual(facts.get("起运地"), "南京")
|
||||
self.assertEqual(facts.get("品名"), "普货")
|
||||
air = next(r for r in sess.quote["fee_rows"] if r["name"] == "空运费")
|
||||
self.assertEqual(str(air.get("unit_price") or ""), "16.50")
|
||||
|
||||
def test_group_brief_keeps_tms_when_current_is_adjust(self) -> None:
|
||||
"""拉群摘要:TMS 段必须是原价,调后价另写销售调价,禁止替换。"""
|
||||
tms = _quote(("运输费", "110.00", "CNY"), total="110.00")
|
||||
|
||||
@@ -1427,6 +1427,64 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
|
||||
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
|
||||
|
||||
def test_air_origin_fee_unit_ci_keeps_qty_and_amount(self) -> None:
|
||||
"""WO081:打板费单位「次」必须认出数量100/金额1000,不能落成数量1、金额=单价。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"@智价通AIQ 起运港,打板费,次,单价CNY10,数量100,金额CNY1000",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("打板费", rows)
|
||||
self.assertEqual(str(rows["打板费"].get("unit") or ""), "次")
|
||||
self.assertEqual(str(rows["打板费"].get("unit_price") or ""), "10")
|
||||
self.assertEqual(str(rows["打板费"].get("quantity") or ""), "100")
|
||||
self.assertEqual(str(rows["打板费"].get("amount") or ""), "1000")
|
||||
self.assertEqual(str(rows["打板费"].get("project") or ""), "起运港")
|
||||
|
||||
def test_air_origin_fee_unit_ren_keeps_qty_and_amount(self) -> None:
|
||||
"""单位「人」同样要能抽出数量与金额。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"起运港,人工费,人,单价CNY50,数量2,金额CNY100",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("人工费", rows)
|
||||
self.assertEqual(str(rows["人工费"].get("unit") or ""), "人")
|
||||
self.assertEqual(str(rows["人工费"].get("quantity") or ""), "2")
|
||||
self.assertEqual(str(rows["人工费"].get("amount") or ""), "100")
|
||||
|
||||
def test_air_origin_fee_unit_slash_ci_ren(self) -> None:
|
||||
"""单位表「次/人」整段保留,不能拆成只认「次」。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"起运港,操作费,次/人,单价CNY20,数量3,金额CNY60",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("操作费", rows)
|
||||
self.assertEqual(str(rows["操作费"].get("unit") or ""), "次/人")
|
||||
self.assertEqual(str(rows["操作费"].get("quantity") or ""), "3")
|
||||
self.assertEqual(str(rows["操作费"].get("amount") or ""), "60")
|
||||
|
||||
def test_air_origin_fee_unit_hour_per_person(self) -> None:
|
||||
"""单位表「小时/人」整段保留。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"起运港,仓储费,小时/人,单价CNY30,数量4,金额CNY120",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("仓储费", rows)
|
||||
self.assertEqual(str(rows["仓储费"].get("unit") or ""), "小时/人")
|
||||
self.assertEqual(str(rows["仓储费"].get("quantity") or ""), "4")
|
||||
self.assertEqual(str(rows["仓储费"].get("amount") or ""), "120")
|
||||
|
||||
def test_fee_line_text_is_a_product_quote(self) -> None:
|
||||
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
@@ -226,7 +226,8 @@ class WshotoPollTests(unittest.TestCase):
|
||||
self.assertTrue(out["ok"])
|
||||
call = api.calls[0]
|
||||
self.assertEqual(call.get("msg_time_end"), 1_800_000_000)
|
||||
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 180)
|
||||
# 新群回看上限见 poll._NEW_ROOM_LOOKBACK_SEC(3600),须盖过存档延迟。
|
||||
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 3600)
|
||||
|
||||
def test_poll_enqueues_member_skips_seat_and_advances_seq(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
@@ -317,6 +318,82 @@ class WshotoPollTests(unittest.TestCase):
|
||||
self.assertEqual(b, 0)
|
||||
self.assertEqual(skipped, 1)
|
||||
|
||||
def test_room_seq_gap_backfill_keeps_dissolve(self) -> None:
|
||||
"""
|
||||
现网事故:增量只先到「撤回」高 seq,游标一步跳到 9899,
|
||||
中间的「解散群」再也拉不回来。跳号时必须时间窗补洞再推进游标。
|
||||
"""
|
||||
store = MemoryMessageStore()
|
||||
redis = _redis()
|
||||
room = "wr_gap_dissolve"
|
||||
watch_collab_room(room, redis=redis)
|
||||
save_last_seq(f"room:{room}", 9892, redis=redis)
|
||||
|
||||
all_rows = [
|
||||
{
|
||||
"msgid": "m9897",
|
||||
"seq": 9897,
|
||||
"from": "ShiChangChang",
|
||||
"room_id": room,
|
||||
"msgtype": "text",
|
||||
"content": "随便说一句",
|
||||
"msgtime": 1_792_546_051_000,
|
||||
},
|
||||
{
|
||||
"msgid": "m9898",
|
||||
"seq": 9898,
|
||||
"from": "ShiChangChang",
|
||||
"room_id": room,
|
||||
"msgtype": "text",
|
||||
"content": "解散群",
|
||||
"msgtime": 1_792_546_055_000,
|
||||
},
|
||||
{
|
||||
"msgid": "m9899",
|
||||
"seq": 9899,
|
||||
"from": "ShiChangChang",
|
||||
"room_id": room,
|
||||
"msgtype": "revoke",
|
||||
"content": "[撤回消息]",
|
||||
"msgtime": 1_792_546_059_000,
|
||||
},
|
||||
]
|
||||
|
||||
class _GapApi:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def list_group_messages(self, **kwargs):
|
||||
self.calls.append(dict(kwargs))
|
||||
last = int(kwargs.get("last_seq") or 0)
|
||||
time_start = int(kwargs.get("msg_time_start") or 0)
|
||||
# 模拟管家增量晚到:先只吐撤回;时间窗才能看到中间「解散群」。
|
||||
if time_start > 0:
|
||||
return [r for r in all_rows if int(r["seq"]) > last]
|
||||
if last > 0:
|
||||
return [r for r in all_rows if int(r["seq"]) == 9899 and int(r["seq"]) > last]
|
||||
return list(all_rows)
|
||||
|
||||
cfg = _Cfg()
|
||||
cfg.wshoto_room_gap_ms = 0
|
||||
out = poll_once(
|
||||
client=_GapApi(),
|
||||
settings=cfg,
|
||||
redis=redis,
|
||||
store=store,
|
||||
now=lambda: 1_792_546_100,
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertGreaterEqual(out["enqueued"], 1)
|
||||
self.assertEqual(load_last_seq(f"room:{room}", redis=redis), 9899)
|
||||
claimed = []
|
||||
while True:
|
||||
item = store.claim_inbound()
|
||||
if item is None:
|
||||
break
|
||||
claimed.append(item.message.content)
|
||||
self.assertIn("解散群", claimed)
|
||||
|
||||
def test_watched_rooms_preferred_without_seat_userid(self) -> None:
|
||||
store = MemoryMessageStore()
|
||||
redis = _redis()
|
||||
|
||||
Reference in New Issue
Block a user