修复尺寸报价及目的地补充的上下文边界
This commit is contained in:
@@ -105,13 +105,13 @@ tests/ # 按业务模块组织的回归
|
||||
## 必须保留的接口与数据约定
|
||||
|
||||
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
|
||||
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径和重量/日期,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。
|
||||
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
|
||||
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
|
||||
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸等简短追问及重试,保留报价条件;新订单、理赔及新的完整问题形成回溯边界。中英混写的 UK/US 按 ASCII 字母边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
|
||||
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
|
||||
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
|
||||
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
|
||||
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
|
||||
|
||||
@@ -9,7 +9,8 @@ from .config import get_setting
|
||||
from .db import connect, last_user_question, load_context, save_message
|
||||
from .knowledge import search_knowledge
|
||||
from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
|
||||
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island)
|
||||
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island,
|
||||
is_shipping_destination_reply)
|
||||
from .tracking import CARD_PREFIX
|
||||
from .order_data import answer_query, extract_reference
|
||||
|
||||
@@ -26,13 +27,15 @@ QUOTE_FOLLOWUP_PREFIX = r'^(?:那(?:么)?|换成|改成|改为|如果|还是|再
|
||||
|
||||
def is_shipping_followup(message, intent=None):
|
||||
"""Recognize bounded quote edits without carrying facts across new topics."""
|
||||
if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外)(?:票|批)|新货|新订单|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message):
|
||||
if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外一?)(?:票|批)|新(?:的)?(?:货|订单)|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message):
|
||||
return False
|
||||
intent = intent if intent is not None else parse_shipping_intent(message)
|
||||
text = message.strip().strip(',,。.!!??、:: ')
|
||||
prefix = re.match(QUOTE_FOLLOWUP_PREFIX, text)
|
||||
if prefix and any(intent.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
|
||||
return True
|
||||
if intent['region'] is not None and is_shipping_destination_reply(text):
|
||||
return True
|
||||
short = re.sub(QUOTE_FOLLOWUP_PREFIX, '', text, count=1).strip()
|
||||
short = re.sub(r'^(?:请问|请|再问一下)', '', short).strip()
|
||||
if re.fullmatch(r'(?:海运|海派|空运|空派|铁路|班列|卡航|卡车|sea(?:\s+freight)?|air(?:\s+freight)?|rail|truck)'
|
||||
@@ -42,7 +45,7 @@ def is_shipping_followup(message, intent=None):
|
||||
return True
|
||||
if intent['weight'] is not None and re.fullmatch(r'\d+(?:\.\d+)?\s*(?:kg|公斤|千克|吨|lbs?|tons?|kilograms?)(?:呢)?', short, re.I):
|
||||
return True
|
||||
return bool(intent['dims'] and re.fullmatch(r'\d+\s*[*xX×]\s*\d+\s*[*xX×]\s*\d+\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I))
|
||||
return bool(intent['dims'] and re.fullmatch(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I))
|
||||
|
||||
|
||||
def shipping_intent_with_context(message, conv_id):
|
||||
@@ -95,7 +98,8 @@ def create_chat_stream(msg, conv_id):
|
||||
re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or
|
||||
(ship['channel'] and (is_shipping_followup(msg, ship) or re.search(r'报价|价格|费用|运费|多少钱|\b(?:quote|cost|price)\b', msg, re.I)))):
|
||||
order_answer = {'t':'请提供目的国家或地区、目的仓或邮编,以便匹配对应费率;当前无法确定报价,可联系人工客服确认。'}
|
||||
if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or re.search(r'报价|价格|运费|费用|多少钱', msg)):
|
||||
if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or
|
||||
is_shipping_destination_reply(msg) or re.search(r'报价|价格|运费|费用|多少钱', msg)):
|
||||
order_answer = {'t':'当前报价表尚未包含该目的地的费率,请提供目的仓或邮编、货物品名、件数和尺寸,由人工客服确认报价。'}
|
||||
if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']:
|
||||
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
|
||||
|
||||
@@ -100,6 +100,8 @@ def extract_reference(message):
|
||||
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
|
||||
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
|
||||
r'(?![A-Za-z0-9_-])', ' ', message)
|
||||
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
|
||||
r'(?:\s*(?:cm|mm|m|厘米|毫米|米))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
|
||||
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
|
||||
r'(?:kg|lbs?|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
|
||||
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
|
||||
|
||||
+32
-34
@@ -95,46 +95,44 @@ RATES = {
|
||||
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
|
||||
|
||||
def parse_dims(t):
|
||||
"""解析 长x宽x高,例如 120*100*100 / 100x80x60 / 120×100×100
|
||||
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
|
||||
|
||||
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
|
||||
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
|
||||
"""
|
||||
for m in re.finditer(r"(\d+)[*xX×](\d+)[*xX×](\d+)", t):
|
||||
# 逐段向后延长,保证拿到的是完整数字而不是半截
|
||||
dims = []
|
||||
ok = True
|
||||
pos = m.start()
|
||||
for g in (1, 2, 3):
|
||||
end = m.end(g)
|
||||
# 若后面还紧跟数字,说明这一组没取全,放弃这个候选
|
||||
if end < len(t) and t[end].isdigit():
|
||||
ok = False
|
||||
break
|
||||
# 若是第一组,前面不能紧挨数字
|
||||
if g == 1 and m.start(1) > 0 and t[m.start(1) - 1].isdigit():
|
||||
ok = False
|
||||
break
|
||||
dims.append(int(m.group(g)))
|
||||
if ok:
|
||||
return [float(x) for x in dims]
|
||||
return None
|
||||
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
|
||||
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
|
||||
return [float(value) for value in match.groups()] if match else None
|
||||
|
||||
|
||||
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
|
||||
('unsupported', r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|'
|
||||
r'(?<![A-Za-z0-9_])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||
r'(?<![A-Za-z0-9_])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])'),
|
||||
('uk', r'英国|伦敦|英格兰|(?<![A-Za-z0-9_])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||
r'(?<![A-Za-z0-9_])(?:United Kingdom|Britain)(?![A-Za-z])'),
|
||||
('eu', r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
|
||||
r'(?<![A-Za-z0-9_])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
|
||||
r'(?<![A-Za-z0-9_])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))'),
|
||||
('unsupported', r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b'),
|
||||
))
|
||||
|
||||
|
||||
def shipping_region(text):
|
||||
if re.search(r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|'
|
||||
r'(?<![A-Za-z])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||
r'(?<![A-Za-z])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])', text, re.I):
|
||||
return 'unsupported'
|
||||
if re.search(r'英国|伦敦|英格兰|(?<![A-Za-z])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||
r'(?<![A-Za-z])(?:United Kingdom|Britain)(?![A-Za-z])', text, re.I):
|
||||
return 'uk'
|
||||
if re.search(r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
|
||||
r'(?<![A-Za-z])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
|
||||
r'(?<![A-Za-z])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))', text, re.I):
|
||||
return 'eu'
|
||||
if re.search(r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b', text, re.I):
|
||||
return 'unsupported'
|
||||
return None
|
||||
return next((region for pattern, region in SHIPPING_REGION_PATTERNS if pattern.search(text)), None)
|
||||
|
||||
|
||||
def is_shipping_destination_reply(text):
|
||||
text = text.strip().strip(',,。.!!??、:: ')
|
||||
for pattern, _ in SHIPPING_REGION_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
continue
|
||||
prefix = re.fullmatch(r'\s*(?:(?:目的地|目的国家|国家)(?:是|为|:|:)?|(?:发|寄|运|送)(?:往|到)?|到|往|去|to)?\s*', text[:match.start()], re.I)
|
||||
suffix = re.fullmatch(r'\s*(?:的)?\s*(?:FBA\s*)?(?:(?:海外)?仓(?:库)?|warehouse)?(?:呢)?\s*', text[match.end():], re.I)
|
||||
if prefix and suffix:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parse_shipping_intent(text):
|
||||
|
||||
@@ -237,6 +237,36 @@ class ApiTests(unittest.TestCase):
|
||||
db.save_message(cid,'user','空运理赔应该怎么办?')
|
||||
self.assertIsNone(shipping_intent_with_context('海运呢',cid)['weight'])
|
||||
|
||||
def test_dimension_quotes_and_changes_do_not_become_order_identifiers(self):
|
||||
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
|
||||
cid=self.conversation()
|
||||
for message, amount in (('30kg英国海运,100x100x100cm','1,817'), ('50x40x30cm呢','384'),
|
||||
('1.2x0.8x0.6m呢','1,229'),
|
||||
('那另外一票50kg德国海运','615')):
|
||||
with self.subTest(message=message):
|
||||
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||
events=frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
|
||||
self.assertEqual(events[-1],{'d':1})
|
||||
self.assertTrue(model.called)
|
||||
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||
self.assertIn(amount,prompt)
|
||||
if '另外一票' in message:
|
||||
self.assertNotIn('体积重',prompt)
|
||||
|
||||
def test_destination_only_replies_continue_the_existing_quote(self):
|
||||
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
|
||||
cid=self.conversation()
|
||||
self.assertIn('目的',frames(self.client.post('/stream_chat',json={'msg':'30kg海运报价','conv_id':cid}))[0]['t'])
|
||||
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||
frames(self.client.post('/stream_chat',json={'msg':'英国','conv_id':cid}))
|
||||
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||
self.assertIn('英国海运',prompt)
|
||||
self.assertIn('384',prompt)
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events=frames(self.client.post('/stream_chat',json={'msg':'US仓','conv_id':cid}))
|
||||
self.assertIn('尚未包含',events[0]['t'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_uk_volume_quotes_do_not_reuse_continental_warehouse_prices(self):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events = frames(self.client.post('/stream_chat',json={'msg':'2方发英国海运多少钱'}))
|
||||
|
||||
@@ -49,10 +49,13 @@ class OrderDataTests(unittest.TestCase):
|
||||
self.assertEqual(self.events('查询 '+value)[0]['card']['no'], self.record['order_no'])
|
||||
self.assertEqual(order_data.lookup_order('FL20261029000'), [])
|
||||
self.assertEqual(order_data.extract_reference('查询 INB20261029001-01'), ['INB20261029001-01'])
|
||||
self.import_rows([{**self.record,'order_no':'BOX100x100x100'}])
|
||||
self.assertEqual(self.events('查询 BOX100x100x100')[0]['card']['no'],'BOX100x100x100')
|
||||
|
||||
def test_weight_dates_and_attachment_urls_are_not_order_identifiers(self):
|
||||
for text in ('2000KG海运报价', '100000 kg海运报价', '30.000000 KG', '2026-10-29',
|
||||
'2026-10-29T16:53:00', '[文件]/uploads/20261029100145_6787.txt'):
|
||||
'2026-10-29T16:53:00', '100x100x100cm', '1200 × 800 × 600 mm',
|
||||
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
|
||||
self.assertEqual(order_data.extract_reference(text), [])
|
||||
events = self.events('帮我查询物流轨迹')
|
||||
self.assertIn('订单号', events[0]['t'])
|
||||
@@ -60,7 +63,8 @@ class OrderDataTests(unittest.TestCase):
|
||||
|
||||
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
|
||||
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
|
||||
'FL202610290001 30.000000 KG', 'FL202610290001 运费多少钱?'):
|
||||
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
|
||||
'FL202610290001 运费多少钱?'):
|
||||
with self.subTest(message=message):
|
||||
event = self.events(message)[0]
|
||||
if '运费' in message:
|
||||
|
||||
@@ -40,6 +40,8 @@ class RuleTests(unittest.TestCase):
|
||||
self.assertEqual(intent['channel'], 'sea')
|
||||
self.assertEqual(pricing.parse_shipping_intent('1吨铁路')['weight'], 1000)
|
||||
self.assertEqual(pricing.parse_shipping_intent('100 lbs air')['weight'], 45.36)
|
||||
self.assertEqual(pricing.parse_shipping_intent('1.2 x 0.8 x 0.6 m 30kg海运')['dims'],[1.2,0.8,0.6])
|
||||
self.assertEqual(pricing.parse_shipping_intent('长10.5宽20.5高30.5')['dims'],[10.5,20.5,30.5])
|
||||
|
||||
def test_shipping_regions_distinguish_country_codes_from_english_pronouns(self):
|
||||
self.assertEqual(pricing.shipping_region('Give us a quote for 30kg to Germany'),'eu')
|
||||
@@ -50,6 +52,11 @@ class RuleTests(unittest.TestCase):
|
||||
self.assertEqual(pricing.shipping_region('30kg发US仓海运'),'unsupported')
|
||||
self.assertIsNone(pricing.shipping_region('talk about us and our work'))
|
||||
self.assertIsNone(pricing.shipping_region('30kg for UKEURO warehouse'))
|
||||
self.assertIsNone(pricing.shipping_region('2GB附件,30kg海运报价'))
|
||||
self.assertTrue(pricing.is_shipping_destination_reply('英国的FBA仓'))
|
||||
self.assertTrue(pricing.is_shipping_destination_reply('目的地是UK仓'))
|
||||
self.assertFalse(pricing.is_shipping_destination_reply('英国VAT费用是什么'))
|
||||
self.assertFalse(pricing.is_shipping_destination_reply('Give us a quote for 30kg to Germany'))
|
||||
|
||||
def test_warehouse_country_and_surcharge(self):
|
||||
result = pricing.calc_warehouse_card(2, '海运', country='DE')
|
||||
|
||||
Reference in New Issue
Block a user