多段激活后无需再带工单号;含空运群回复走机器人;复制补问清单时空货好时间不再吃成下一行编号。 Co-authored-by: Cursor <cursoragent@cursor.com>
653 lines
27 KiB
Python
653 lines
27 KiB
Python
"""
|
||
口述空运抽字段:解析 DeepSeek B JSON,不打外网。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import unittest
|
||
from types import SimpleNamespace
|
||
|
||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||
if ROOT not in sys.path:
|
||
sys.path.insert(0, ROOT)
|
||
|
||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||
|
||
from agent.llm.extract_text import (
|
||
detect_transport_mode,
|
||
extract_inquiry_snapshot,
|
||
parse_labeled_facts,
|
||
)
|
||
from agent.schema.field_validate import normalize_facts
|
||
from agent.llm.mode_extract_fields import invoke_shell, parse_extract_payload
|
||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||
from agent.ledger.memory_ledger import MemoryLedger
|
||
|
||
ORAL = "上海到洛杉矶空运,3件 180kg,体积2.5,普货纸箱"
|
||
|
||
B_JSON = {
|
||
"transport_mode": "AIR",
|
||
"fields": {
|
||
"起运港": "上海",
|
||
"目的港": "洛杉矶",
|
||
"品名": "普货",
|
||
"件数": "3",
|
||
"毛重": "180",
|
||
"体积": "2.5",
|
||
"包装方式": "纸箱",
|
||
"报价日期": "",
|
||
},
|
||
}
|
||
|
||
|
||
class OralExtractTests(unittest.TestCase):
|
||
def test_parse_b_json(self) -> None:
|
||
parsed = parse_extract_payload(B_JSON)
|
||
self.assertTrue(parsed["ok"])
|
||
self.assertEqual(parsed["business_line"], "AIR")
|
||
self.assertEqual(parsed["facts"]["起运港"], "上海")
|
||
self.assertNotIn("报价日期", parsed["facts"])
|
||
|
||
def test_invoke_shell_with_json(self) -> None:
|
||
out = invoke_shell(payload={"assistant_json": B_JSON})
|
||
self.assertTrue(out["ok"])
|
||
self.assertEqual(out["facts"]["目的港"], "洛杉矶")
|
||
|
||
def test_invoke_shell_without_json_keeps_legacy(self) -> None:
|
||
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")
|
||
self.assertEqual(snap["facts"]["件数"], "3件")
|
||
self.assertEqual(snap["source"], "deepseek_b")
|
||
|
||
def test_oral_without_b_does_not_guess_ports(self) -> None:
|
||
snap = extract_inquiry_snapshot(ORAL)
|
||
self.assertEqual(snap["business_line"], "AIR")
|
||
self.assertFalse(snap["facts"].get("起运港"))
|
||
|
||
def test_bare_dap_is_trade_term(self) -> None:
|
||
"""句尾单独的 DAP 记成贸易条款,不依赖模型有没有这个字段。"""
|
||
snap = extract_inquiry_snapshot("查一下空运,出货日期9月30日广州到香港的普货价格,DAP")
|
||
self.assertEqual(snap["business_line"], "AIR")
|
||
self.assertEqual(snap["facts"].get("贸易条款"), "DAP")
|
||
|
||
def test_trade_term_example_line_is_not_filled(self) -> None:
|
||
snap = extract_inquiry_snapshot("贸易条款(非必填):\n(如:DDP、DAP、CIF、CIP)")
|
||
self.assertFalse(snap["facts"].get("贸易条款"))
|
||
labeled = extract_inquiry_snapshot("贸易条款:CIF\n(如:DDP、DAP、CIF、CIP)")
|
||
self.assertEqual(labeled["facts"].get("贸易条款"), "CIF")
|
||
|
||
def test_labeled_customs_port(self) -> None:
|
||
self.assertEqual(parse_labeled_facts("通关口岸:皇岗").get("通关口岸"), "皇岗")
|
||
snap = extract_inquiry_snapshot("通关口岸(非必填):皇岗")
|
||
self.assertEqual(snap["facts"].get("通关口岸"), "皇岗")
|
||
snap2 = extract_inquiry_snapshot("通关口岸皇岗")
|
||
self.assertEqual(snap2["facts"].get("通关口岸"), "皇岗")
|
||
|
||
def test_b_keeps_land_customs_port(self) -> None:
|
||
parsed = parse_extract_payload(
|
||
{
|
||
"transport_mode": "LAND",
|
||
"fields": {"起运港": "广州", "通关口岸": "皇岗"},
|
||
}
|
||
)
|
||
self.assertEqual(parsed["facts"].get("通关口岸"), "皇岗")
|
||
|
||
def test_assemble_air_query_zhuhai_clark(self) -> None:
|
||
from agent.schema.tms_air_query import assemble_air_query
|
||
|
||
out = assemble_air_query(
|
||
{
|
||
"起运港": "珠海",
|
||
"目的港": "克拉克",
|
||
"品名": "茶叶",
|
||
"件数": "10件",
|
||
"毛重": "100KGS",
|
||
"体积": "2CBM",
|
||
"包装方式": "托盘",
|
||
"报价日期": "2026-09-12",
|
||
}
|
||
)
|
||
self.assertTrue(out["ok"])
|
||
self.assertEqual(out["facts"]["originCode"], "ZUH")
|
||
self.assertEqual(out["facts"]["destinationCode"], "CRK")
|
||
self.assertEqual(out["facts"]["packageType"], "托盘")
|
||
self.assertEqual(out["facts"]["件数"], "10件")
|
||
|
||
bulk = assemble_air_query(
|
||
{
|
||
"起运港": "珠海",
|
||
"目的港": "克拉克",
|
||
"品名": "茶叶",
|
||
"件数": "10",
|
||
"毛重": "100",
|
||
"体积": "2",
|
||
"包装方式": "散货",
|
||
"报价日期": "2026-09-12",
|
||
}
|
||
)
|
||
self.assertTrue(bulk["ok"])
|
||
self.assertEqual(bulk["facts"]["包装方式"], "散货")
|
||
self.assertNotIn("包装方式", bulk["missing"])
|
||
|
||
def test_place_stops_before_cargo_and_price(self) -> None:
|
||
"""目的地只留地名。后面的品名、包装、的价格不能粘在地名上。"""
|
||
from agent.schema.field_validate import validate_required_fields
|
||
|
||
check = validate_required_fields(
|
||
facts={
|
||
"目的港": "吉隆坡,普货,散货的价格",
|
||
"品名": "普货",
|
||
"包装方式": "散货",
|
||
"报价日期": "2026-09-29",
|
||
},
|
||
business_line="AIR",
|
||
)
|
||
self.assertEqual(check["facts"]["目的港"], "吉隆坡")
|
||
self.assertEqual(check["facts"]["品名"], "普货")
|
||
self.assertEqual(check["facts"]["包装方式"], "散货")
|
||
bare = validate_required_fields(
|
||
facts={"目的港": "洛杉矶,茶叶,纸箱的价格"},
|
||
business_line="AIR",
|
||
)
|
||
self.assertEqual(bare["facts"]["目的港"], "洛杉矶")
|
||
self.assertEqual(bare["facts"]["品名"], "茶叶")
|
||
self.assertEqual(bare["facts"]["包装方式"], "纸箱")
|
||
|
||
def test_fly_route_overwrites_stale_nkg_kul(self) -> None:
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
from agent.schema.tms_air_query import assemble_air_query
|
||
from agent.policy import inquiry_copy as copy
|
||
|
||
first = harvest_oral_measures(
|
||
"从南京飞吉隆坡,9月14日茶叶,托盘,4.2cbm,1.5KG",
|
||
{},
|
||
)
|
||
self.assertEqual(first["起运港"], "南京")
|
||
self.assertEqual(first["目的港"], "吉隆坡")
|
||
assembled = assemble_air_query(
|
||
{**first, "originCode": "NKG", "destinationCode": "KUL", "件数": "10"}
|
||
)
|
||
card = copy.inquiry_card(work_order_no="WO1", facts=assembled["facts"])
|
||
self.assertIn("起运地:南京", card)
|
||
self.assertIn("目的地:吉隆坡", card)
|
||
self.assertNotIn("起运地:NKG", card)
|
||
self.assertNotIn("目的地:KUL", card)
|
||
second = harvest_oral_measures(
|
||
"北京飞吉隆坡,9月14日茶叶,4.2cbm,1.5KG",
|
||
{**assembled["facts"]},
|
||
)
|
||
again = assemble_air_query(second)
|
||
self.assertEqual(again["facts"]["起运港"], "北京")
|
||
self.assertEqual(again["facts"]["originCode"], "PEK")
|
||
self.assertEqual(again["facts"]["destinationCode"], "KUL")
|
||
shown = copy.inquiry_card(work_order_no="WO2", facts=again["facts"])
|
||
self.assertIn("起运地:北京", shown)
|
||
self.assertNotIn("起运地:NKG", shown)
|
||
self.assertNotIn("起运地:南京", shown)
|
||
|
||
def test_iata_pair_kept_not_rewritten_to_city(self) -> None:
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
from agent.schema.tms_air_query import assemble_air_query
|
||
from agent.policy import inquiry_copy as copy
|
||
|
||
oral = "空运 SZX-CGK 发光二级管组件 11P 2400KG 20cbm"
|
||
facts = harvest_oral_measures(
|
||
oral,
|
||
{"起运港": "深圳", "目的港": "雅加达", "品名": "发光二级管组件"},
|
||
)
|
||
self.assertEqual(facts["起运港"], "SZX")
|
||
self.assertEqual(facts["目的港"], "CGK")
|
||
assembled = assemble_air_query(
|
||
{
|
||
**facts,
|
||
"件数": "11P",
|
||
"毛重": "2400KG",
|
||
"体积": "20cbm",
|
||
"包装方式": "托盘",
|
||
"报价日期": "2026-09-18",
|
||
}
|
||
)
|
||
self.assertEqual(assembled["facts"]["originCode"], "SZX")
|
||
self.assertEqual(assembled["facts"]["destinationCode"], "CGK")
|
||
card = copy.inquiry_card(work_order_no="WO1", facts=assembled["facts"])
|
||
self.assertIn("起运地:SZX", card)
|
||
self.assertIn("目的地:CGK", card)
|
||
self.assertNotIn("深圳", card)
|
||
self.assertNotIn("雅加达", card)
|
||
|
||
def test_currency_pair_is_not_air_route(self) -> None:
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
|
||
facts = harvest_oral_measures(
|
||
"空运 货值 USD-CNY 茶叶",
|
||
{"起运港": "上海", "目的港": "洛杉矶"},
|
||
)
|
||
self.assertEqual(facts["起运港"], "上海")
|
||
self.assertEqual(facts["目的港"], "洛杉矶")
|
||
|
||
def test_oral_pack_and_quote_date_from_current_sentence(self) -> None:
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
|
||
first = harvest_oral_measures(
|
||
"南京飞吉隆坡,9月15日茶叶,托盘,4.2cbm,1.5KG",
|
||
{},
|
||
)
|
||
self.assertEqual(first["包装方式"], "托盘")
|
||
self.assertEqual(first["报价日期"], "2026-09-15")
|
||
self.assertEqual(first["毛重"], "1.5KG")
|
||
self.assertEqual(first["体积"], "4.2cbm")
|
||
second = harvest_oral_measures(
|
||
"京飞吉隆坡,9月16日茶叶,散货,5.2cbm,2.5KG",
|
||
first,
|
||
)
|
||
self.assertEqual(second["包装方式"], "散货")
|
||
self.assertEqual(second["报价日期"], "2026-09-16")
|
||
self.assertEqual(second["毛重"], "2.5KG")
|
||
self.assertEqual(second["体积"], "5.2cbm")
|
||
|
||
def test_clarify_replaces_pallet_with_bulk_and_keeps_quote_date(self) -> None:
|
||
replies: list[str] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
replies.append(text)
|
||
_ = extra
|
||
|
||
flow = AirTextInquiryFlow(MemoryLedger())
|
||
flow.on_text(
|
||
sender_id="u_date",
|
||
text="南京飞吉隆坡,9月15日茶叶,托盘,4.2cbm,1.5KG",
|
||
reply=reply,
|
||
injected_facts={
|
||
"起运港": "南京",
|
||
"目的港": "吉隆坡",
|
||
"品名": "茶叶",
|
||
"包装方式": "托盘",
|
||
},
|
||
injected_mode="AIR",
|
||
)
|
||
self.assertEqual(flow.session_of("u_date").facts["包装方式"], "托盘")
|
||
self.assertEqual(flow.session_of("u_date").facts["报价日期"], "2026-09-15")
|
||
flow.on_text(
|
||
sender_id="u_date",
|
||
text="京飞吉隆坡,9月16日茶叶,散货,5.2cbm,2.5KG",
|
||
reply=reply,
|
||
injected_facts={
|
||
"起运港": "南京",
|
||
"目的港": "吉隆坡",
|
||
"品名": "茶叶",
|
||
},
|
||
injected_mode="AIR",
|
||
)
|
||
sess = flow.session_of("u_date")
|
||
self.assertEqual(sess.facts["包装方式"], "散货")
|
||
self.assertEqual(sess.facts["报价日期"], "2026-09-16")
|
||
last = replies[-1]
|
||
self.assertIn("包装类型:散货", last)
|
||
self.assertIn("报价日期:2026-09-16", last)
|
||
|
||
def test_weight_keeps_user_kg_not_kgs(self) -> None:
|
||
from agent.policy import inquiry_copy as copy
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
|
||
facts = harvest_oral_measures("从南京飞吉隆坡,茶叶,1.5KG,4.2cbm,托盘", {})
|
||
self.assertEqual(facts["毛重"], "1.5KG")
|
||
shown = copy.inquiry_card(
|
||
work_order_no="WO1",
|
||
facts={**facts, "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", "件数": "10"},
|
||
)
|
||
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 托盘"
|
||
)
|
||
self.assertEqual(snap["facts"]["件数"], "10件")
|
||
self.assertEqual(snap["facts"]["毛重"], "100KGS")
|
||
self.assertEqual(snap["facts"]["体积"], "2CBM")
|
||
self.assertFalse(snap["facts"].get("起运港"))
|
||
|
||
def test_labeled_weight_alias(self) -> None:
|
||
from agent.llm.extract_text import parse_labeled_facts
|
||
from agent.schema.field_validate import normalize_facts
|
||
|
||
facts = parse_labeled_facts("重量(KG):180\n体积(CBM):2.5")
|
||
norm = normalize_facts(facts)
|
||
self.assertEqual(norm["毛重"], "180")
|
||
self.assertEqual(norm["体积"], "2.5")
|
||
|
||
def test_extract_prompt_keeps_iata(self) -> None:
|
||
from agent.llm.mode_extract_fields import build_extract_messages
|
||
|
||
blob = str(build_extract_messages("空运 SZX-CGK"))
|
||
self.assertIn("SZX-CGK", blob)
|
||
self.assertIn("禁止翻译成城市名", blob)
|
||
|
||
def test_chat_fn_fixture_drives_flow(self) -> None:
|
||
def chat_fn(**kwargs):
|
||
return SimpleNamespace(
|
||
ok=True,
|
||
content="",
|
||
error="",
|
||
tool_calls=[
|
||
{
|
||
"function": {
|
||
"name": "extract_inquiry_fields",
|
||
"arguments": __import__("json").dumps(B_JSON, ensure_ascii=False),
|
||
}
|
||
}
|
||
],
|
||
)
|
||
|
||
replies: list[str] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
replies.append(text)
|
||
_ = extra
|
||
|
||
flow = AirTextInquiryFlow(MemoryLedger())
|
||
phase = flow.on_text(
|
||
sender_id="oral1",
|
||
text=ORAL,
|
||
reply=reply,
|
||
chat_fn=chat_fn,
|
||
)
|
||
if phase == "wait_confirm":
|
||
phase = flow.on_text(sender_id="oral1", text="确定", reply=reply)
|
||
self.assertEqual(phase, "wait_collab")
|
||
joined = "\n".join(replies)
|
||
self.assertIn("工单号:", joined)
|
||
self.assertIn("起运地:上海", joined)
|
||
self.assertIn("目的地:洛杉矶", joined)
|
||
self.assertNotIn("【询价卡片】", joined)
|
||
|
||
def test_mode_index_maps_land_sea_air(self) -> None:
|
||
self.assertEqual(detect_transport_mode("1"), "LAND")
|
||
self.assertEqual(detect_transport_mode("2"), "SEA")
|
||
self.assertEqual(detect_transport_mode("3"), "AIR")
|
||
self.assertEqual(detect_transport_mode("1.陆运"), "LAND")
|
||
self.assertEqual(detect_transport_mode("空运"), "AIR")
|
||
self.assertEqual(detect_transport_mode("10件"), "")
|
||
self.assertEqual(detect_transport_mode("1.件数(必填):10"), "")
|
||
|
||
|
||
class ClarifyPasteTests(unittest.TestCase):
|
||
def test_paste_numbered_required_lines_and_skip_examples(self) -> None:
|
||
"""复制补问清单:只收冒号后的值,示例行和空项都不算填了。"""
|
||
text = """
|
||
1.件数(必填):10
|
||
(如:10)
|
||
2.重量(KG)(必填):10
|
||
(如:100)
|
||
3.体积(CBM)(必填):10
|
||
(如:2)
|
||
4.包装类型(必填):
|
||
(如:托盘、散货)
|
||
"""
|
||
facts = normalize_facts(parse_labeled_facts(text))
|
||
self.assertEqual(facts.get("件数"), "10")
|
||
self.assertEqual(facts.get("毛重"), "10")
|
||
self.assertEqual(facts.get("体积"), "10")
|
||
self.assertNotIn("包装方式", facts)
|
||
self.assertNotEqual(facts.get("包装方式"), "托盘、散货")
|
||
|
||
def test_blank_cargo_ready_does_not_take_next_item_number(self) -> None:
|
||
"""货好时间空着时,下一行「4.提货地址」的编号不能写进货好时间。示例行也不算填了。"""
|
||
text = """
|
||
1.包装类型(必填):托盘
|
||
(如:托盘、散货)
|
||
2.货源地(非必填):
|
||
(如:东莞)
|
||
3.货好时间(非必填):
|
||
(如:随时可提)
|
||
4.提货地址(非必填):
|
||
(如:东莞仓库)
|
||
5.收货地址(非必填):
|
||
(如:马尼拉)
|
||
6.贸易条款(非必填):
|
||
(如:DDP、DAP、CIF、CIP)
|
||
"""
|
||
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
|
||
self.assertEqual(facts.get("包装方式") or facts.get("包装类型"), "托盘")
|
||
self.assertNotIn("货好时间", facts)
|
||
self.assertNotIn("提货地址", facts)
|
||
self.assertNotIn("货源地", facts)
|
||
self.assertNotIn("贸易条款", facts)
|
||
|
||
|
||
class ImageOptionalClipTests(unittest.TestCase):
|
||
def test_numbered_extra_lines_do_not_enter_fields(self) -> None:
|
||
oral = """
|
||
运输方式:空运
|
||
目的地:印尼巴淡
|
||
件数:1PLT
|
||
重量:55.8KGS
|
||
体积:1.23CBM
|
||
包装类型:托盘
|
||
报价日期:2026-09-23
|
||
起运地:深圳坪山保税仓
|
||
货好时间:今天货好
|
||
4. 希望送到目的地工厂时间: 尽快
|
||
5.
|
||
提货地址:深圳市坪山区龙田街道大工业区绿荫北路7号601、
|
||
602、603 (保税仓库)
|
||
6. 送货地址: PT SAT NUSAPERSADA TBK----JL. PELITA VI NO.99
|
||
"""
|
||
snap = extract_inquiry_snapshot(oral, allow_b=False)
|
||
facts = snap["facts"]
|
||
self.assertEqual(facts.get("货好时间"), "今天货好")
|
||
self.assertIn("坪山区", facts.get("提货地址") or "")
|
||
self.assertNotIn("希望送到", facts.get("货好时间") or "")
|
||
self.assertNotIn("希望送到", facts.get("提货地址") or "")
|
||
self.assertNotIn("送货地址", facts.get("提货地址") or "")
|
||
self.assertIn("PT SAT", facts.get("收货地址") or "")
|
||
from agent.policy import inquiry_copy as copy
|
||
|
||
card = copy.ask_clarify(
|
||
facts=facts,
|
||
missing_keys=["品名"],
|
||
transport_mode="空运",
|
||
)
|
||
self.assertNotIn("希望送到", card)
|
||
self.assertNotIn("4. 希望送到", card)
|
||
self.assertIn("收货地址", card)
|
||
|
||
|
||
# 测服手工海运报价单:表头和格子在同一行,还带 POL/POD/Terms。
|
||
SEA_SHEET_ORAL = """
|
||
起运港/POL(有指定起运港口的必填) 纽约港
|
||
*品名/Commodity: 机器设备 目的港/POD(有指定目的港口的必填) 巴生西港
|
||
*货物数量/Quantity (或提供装箱单/Packing List) 18
|
||
整柜或拼柜:FCL
|
||
箱型箱量:40HQ*1
|
||
贸易条款/Terms: CIF 要求运输时效/Requested Transit Time
|
||
运输分类:其他
|
||
报价日期:2026-09-07
|
||
"""
|
||
|
||
|
||
class SeaSheetHeaderClipTests(unittest.TestCase):
|
||
def test_quote_sheet_headers_do_not_stick_to_values(self) -> None:
|
||
"""报价单表头不能进补问:起运港只要纽约港,贸易条款只要 CIF。"""
|
||
snap = extract_inquiry_snapshot(SEA_SHEET_ORAL, allow_b=False)
|
||
facts = snap["facts"]
|
||
self.assertEqual(facts.get("起运港"), "纽约港")
|
||
self.assertEqual(facts.get("目的港"), "巴生西港")
|
||
self.assertEqual(facts.get("品名"), "机器设备")
|
||
self.assertEqual(str(facts.get("货量") or facts.get("货物数量") or ""), "18")
|
||
self.assertEqual(facts.get("整柜或拼柜"), "FCL")
|
||
self.assertEqual(facts.get("箱型箱量"), "40HQ*1")
|
||
self.assertEqual(facts.get("贸易条款"), "CIF")
|
||
self.assertNotIn("必填", facts.get("起运港") or "")
|
||
self.assertNotIn("Terms", facts.get("贸易条款") or "")
|
||
self.assertNotIn("Commodity", facts.get("品名") or "")
|
||
self.assertNotIn("时效", facts.get("贸易条款") or "")
|
||
|
||
from agent.policy import inquiry_copy as copy
|
||
|
||
card = "\n".join(
|
||
copy.inquiry_field_lines(
|
||
work_order_no="",
|
||
facts=facts,
|
||
transport_mode="海运",
|
||
)
|
||
)
|
||
self.assertIn("纽约港", card)
|
||
self.assertNotIn("口的必填", card)
|
||
self.assertNotIn("Commodity", card)
|
||
self.assertNotIn("Requested Transit", card)
|
||
|
||
def test_next_column_header_is_not_the_value(self) -> None:
|
||
"""
|
||
手工海运单同一行是「字段、格子、下一列表头」。
|
||
目的港只要巴生西港,不能收成费用汇总/POD Local Charges。
|
||
"""
|
||
oral = """
|
||
起运港/POL(有指定起运港口的必填) 纽约港 目的港/POD(有指定目的港口的必填) 巴生西港 费用汇总/POD Local Charges
|
||
*品名/Commodity: 机器设备 *货物数量/Quantity (或提供装箱单/Packing List) 18 提货地址/Place of Receipt
|
||
整柜或拼柜:FCL 箱型箱量:40HQ*1 送货地址/Place of Delivery
|
||
贸易条款/Terms: CIF 要求货物抵达时间/Requested Arrival Time
|
||
运输分类:其他
|
||
报价日期:2026-09-07
|
||
"""
|
||
facts = extract_inquiry_snapshot(oral, allow_b=False)["facts"]
|
||
self.assertEqual(facts.get("起运港"), "纽约港")
|
||
self.assertEqual(facts.get("目的港"), "巴生西港")
|
||
self.assertEqual(facts.get("品名"), "机器设备")
|
||
self.assertEqual(str(facts.get("货量") or facts.get("货物数量") or ""), "18")
|
||
self.assertEqual(facts.get("整柜或拼柜"), "FCL")
|
||
self.assertEqual(facts.get("箱型箱量"), "40HQ*1")
|
||
self.assertEqual(facts.get("贸易条款"), "CIF")
|
||
self.assertNotIn("费用汇总", facts.get("目的港") or "")
|
||
self.assertNotIn("提货地址", facts.get("货量") or facts.get("货物数量") or "")
|
||
self.assertNotIn("送货地址", facts.get("整柜或拼柜") or "")
|
||
self.assertNotIn("抵达", facts.get("箱型箱量") or "")
|
||
|
||
def test_quote_date_synonyms_for_sea_land_air(self) -> None:
|
||
"""询价/走货/发货/出货/提货的日期或时间,海运陆运空运都记成报价日期。"""
|
||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS, normalize_facts
|
||
|
||
modes = (("海运", "SEA"), ("陆运", "LAND"), ("空运", "AIR"))
|
||
for label in QUOTE_DATE_SYNONYMS:
|
||
self.assertEqual(normalize_facts({label: "2026-09-23"})["报价日期"], "2026-09-23")
|
||
for word, mode in modes:
|
||
snap = extract_inquiry_snapshot(f"{word}\n{label}:2026-09-23", allow_b=False)
|
||
self.assertEqual(snap["business_line"], mode, label)
|
||
self.assertEqual(snap["facts"].get("报价日期"), "2026-09-23", label)
|
||
self.assertNotIn(label, snap["facts"])
|
||
|
||
def test_quote_date_synonym_does_not_steal_ready_or_pickup(self) -> None:
|
||
"""货好时间、提货地址仍是原字段;同义词日期不被前面的日期抢走。"""
|
||
snap = extract_inquiry_snapshot(
|
||
"海运\n货好时间:9月20日\n提货地址:上海仓库\n询价日期:2026-09-15",
|
||
allow_b=False,
|
||
)
|
||
facts = snap["facts"]
|
||
self.assertEqual(facts.get("报价日期"), "2026-09-15")
|
||
self.assertEqual(facts.get("货好时间"), "9月20日")
|
||
self.assertEqual(facts.get("提货地址"), "上海仓库")
|
||
oral = extract_inquiry_snapshot("空运 发货日期9月15日", allow_b=False)
|
||
self.assertEqual(oral["facts"].get("报价日期"), "2026-09-15")
|
||
|
||
|
||
# 测服 2026-10-05:空运报价单整表被抽进补问,费用和条款进了起运地、货好时间。
|
||
AIR_QUOTE_SHEET = """
|
||
运输方式:空运
|
||
目的地:CRK
|
||
品名:化妆品
|
||
件数:11
|
||
重量:1KG
|
||
体 积: 100 CBM
|
||
报价日期:46307
|
||
货好时间:
|
||
体 积: 100 CBM 时效要求:
|
||
计 费 重: 10 KG
|
||
派送地址:
|
||
二、报价明细
|
||
项目 费用名称 单位 币别 单价 数量 金额 备注
|
||
空运段 空运费
|
||
起运港 操作费
|
||
文件费 如需多个分单另计
|
||
*代理报关费 发生时收取
|
||
*提货费 发生时收取
|
||
其他 *其他杂费 发生时收取
|
||
预估费用 0
|
||
三、报价条件
|
||
1、报价适用范围:一般货物(非危险品)
|
||
贸易条款:DAP
|
||
托运方:某某有限公司 承运方:深圳市友通达国际货运代理有限公司
|
||
"""
|
||
|
||
|
||
class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
|
||
def test_quote_sheet_keeps_inquiry_fields_only(self) -> None:
|
||
"""费用明细、报价条件、落款不能进补问。起运地不能收成操作费。"""
|
||
snap = extract_inquiry_snapshot(AIR_QUOTE_SHEET, allow_b=False)
|
||
facts = snap["facts"]
|
||
self.assertEqual(snap["business_line"], "AIR")
|
||
self.assertEqual(facts.get("目的港"), "CRK")
|
||
self.assertEqual(facts.get("品名"), "化妆品")
|
||
self.assertIn("11", str(facts.get("件数") or ""))
|
||
self.assertIn("1", str(facts.get("毛重") or ""))
|
||
self.assertNotIn("10", str(facts.get("毛重") or ""))
|
||
self.assertIn("100", str(facts.get("体积") or ""))
|
||
self.assertEqual(facts.get("报价日期"), "2026-10-12")
|
||
self.assertNotIn("起运港", facts)
|
||
self.assertNotIn("货好时间", facts)
|
||
self.assertNotIn("贸易条款", facts)
|
||
blob = "\n".join(facts.values())
|
||
self.assertNotIn("操作费", blob)
|
||
self.assertNotIn("报价明细", blob)
|
||
self.assertNotIn("报价条件", blob)
|
||
self.assertNotIn("空舱", blob)
|
||
self.assertNotIn("友通达", blob)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|