feat: 海运询价必填加上贸易条款和运输分类

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 17:45:08 +08:00
co-authored by Cursor
parent 54b818915e
commit 27aeebb593
4 changed files with 76 additions and 6 deletions
+2 -2
View File
@@ -22,13 +22,13 @@ _MODE_WORDS = (
# 仅「键:值」一行一条,键必须是合同字段或展示名
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
)
+3 -1
View File
@@ -41,7 +41,7 @@ _AIR_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
)
# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
_SEA_EXAMPLES = {
"起运港": "上海",
"目的港": "洛杉矶",
@@ -49,6 +49,8 @@ _SEA_EXAMPLES = {
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
"运输分类": "Port to Port",
"报价日期": "2026-09-15",
}
_SEA_CLARIFY = tuple(
+11 -2
View File
@@ -32,7 +32,7 @@ AIR_REQUIRED_FIELDS = (
)
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
# 海运询价必填 7 项:销售侧名称。内部键「货量」对外叫「货物数量」。
# 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。
SEA_REQUIRED_FIELDS = (
("起运港", "起运港"),
("目的港", "目的港"),
@@ -40,6 +40,8 @@ SEA_REQUIRED_FIELDS = (
("货量", "货物数量"),
("整柜或拼柜", "整柜或拼柜"),
("箱型箱量", "箱型箱量"),
("贸易条款", "贸易条款"),
("运输分类", "运输分类"),
("报价日期", "报价日期"),
)
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
@@ -57,7 +59,7 @@ LAND_DISPLAY = {
}
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
# 空运群只补含电/含磁,摘要不要把海运六项搬过去。
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
@@ -485,6 +487,13 @@ def validate_required_fields(
line = (business_line or "").strip().upper()
if line == "SEA":
normalized = project_sea_cargo_qty(normalized)
from agent.schema.sea_options import canonicalize_sea_class
klass = canonicalize_sea_class(str(normalized.get("运输分类") or ""))
if klass:
normalized["运输分类"] = klass
else:
normalized.pop("运输分类", None)
if line not in {"SEA", "AIR", "LAND"}:
return {
"ok": False,
+60 -1
View File
@@ -29,7 +29,7 @@ from agent.handlers.text_inquiry import handle_text_inquiry
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
from agent.schema.field_validate import validate_required_fields
from agent.schema.field_validate import SEA_COLLAB_FIELDS, validate_required_fields
from agent.schema.tms_sea_query import assemble_sea_query
@@ -43,6 +43,15 @@ COMPLETE_SEA = {
}
class SeaCollabContractTests(unittest.TestCase):
def test_collab_no_longer_asks_trade_terms(self):
self.assertNotIn("贸易条款", SEA_COLLAB_FIELDS)
self.assertEqual(
SEA_COLLAB_FIELDS,
("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁"),
)
class FakeGroupClient:
"""单测假企微建群:不打外网。"""
@@ -123,6 +132,8 @@ class SeaTextInquiryTests(unittest.TestCase):
"件数": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
"运输分类": "Port to Port",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertTrue(check["ok"])
@@ -136,6 +147,8 @@ class SeaTextInquiryTests(unittest.TestCase):
"品名": "茶叶",
"货量": "20吨",
"整柜或拼柜": "整柜",
"贸易条款": "FOB",
"运输分类": "Port to Port",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertFalse(check["ok"])
@@ -157,6 +170,52 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertNotIn("报价日期:(如", text)
self.assertIn("报价日期:", text)
def test_sea_requires_trade_terms_and_class(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertFalse(check["ok"])
self.assertIn("贸易条款", check["missing"])
self.assertIn("运输分类", check["missing"])
self.assertIn("贸易条款", check["missing_display"])
self.assertIn("运输分类", check["missing_display"])
def test_sea_rejects_door_to_door_class(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
"运输分类": "门到门",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertFalse(check["ok"])
self.assertIn("运输分类", check["missing"])
def test_sea_canonicalizes_p2p(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "到岸",
"运输分类": "p2p",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertTrue(check["ok"])
self.assertEqual(check["facts"]["运输分类"], "Port to Port")
def test_complete_sea_inquiry_card_uses_seven_fields(self) -> None:
phase = self.flow.on_text(
sender_id="s1",