diff --git a/AGENTS.md b/AGENTS.md index c1b25ee..2c58f6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees | | US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` | | Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries | +| Reply boundaries / list pages | `logistics_chat/customer_faq.py`: supported actions; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_listing.py`: scoped counts and conversation pages | | Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` | | Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` | | Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` | diff --git a/docs/architecture.md b/docs/architecture.md index e7bb8bd..8acab80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,6 +145,16 @@ tests/ # 按业务模块组织的回归 `shipping_faq.py` 从激活版本回答交仓、标签、报关资料及查验费用问题;具体船期和计划发货日期需人工核对,不使用旧表日期承诺必达。`order_explanations.py` 只解释授权订单的业务状态和实际节点,不把预计节点当实际位置,不推断逐箱签收或亚马逊上架。文字事实回复与订单卡片同样绑定客户及范围;催单或拦截不声称已提交。本轮不扩展售后理赔规则。 +`customer_faq.py` 在报价分流前处理下单、短信查单、开票、导出及附件读取等能力说明,不生成业务操作成功的假回复。美国常见费用/毛净重/议价/总价追问由当前价表直接回答;`quote_scope.py` 将尚未匹配的美国咨询保留为明确的核对回复,不送入旧欧洲/英国知识库。明确切换其他国家时仍走该地区原有分流,理赔规则未扩展。 + +`quote_edits.py` 先移除被明确否定的仓码或交货仓,再解析替换条件。共享尺寸解析支持英寸转换和口语长宽高,“一个箱子”可形成单箱条件;总计立方米不作为尺寸单位“米”。 + +`order_listing.py` 负责列表与下一页/上一页/指定页码,每页20票;从当前会话最近的列表回复恢复页码和业务状态,重新以服务端客户范围查询实际总数和该页数据。COUNT与分页读取处于同一只读事务;无有效列表上下文时要求先列订单,不推测剩余数量。页码有界,筛选及翻页回复与订单事实一样保护历史,不增加公开API或管理入口。 + +已确认海派计费重保留原小数,不向上取整,每箱最低12KG仍生效。原实重小数原样展示;无限小数的材积结果以“约”展示有限位数,金额计算仍使用原Decimal精度,不把显示近似当作计费进位。 + +铁铝附加费只按铁铝货物计费重计算。`quote_material.py` 区分整票铁铝、部分铁铝与未明确材质的金属,并将明确的“铁铝部分计费重”从整票毛重解析中分离。部分货物未提供计费重时不按整票加费;提供明确基数后可将该项人民币费用加到公斤或按方运费中。更改整票重量、箱数或箱规后清除旧的部分计费重,防止沿用过期基数。未明确金属是否属于铁铝时先核实材质,不把所有金属都认作铁铝。 + 先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。 路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。 diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index 51951e3..1ee1713 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -91,14 +91,18 @@ def create_chat_stream(msg, conv_id): return (sse(order_login_response()) + sse({'d':1}), {}) return (sse({'e':'该会话已删除,请新建对话后重试。'}), {}) order_answer = answer_query(msg, conv_id) + if order_answer is None: + from .customer_faq import answer_capability + order_answer = answer_capability(msg) if order_answer is None: order_answer = answer_quote(msg, conv_id) if order_answer is None and re.search(r'(?:转|联系|接入|找).*人工|人工客服',msg): order_answer = {'t':'点击“联系人工”可发起人工客服申请;非工作时间可留言。'} - if order_answer is None and re.search(r'照片|图片',msg) and re.search(r'报价|价格|判断|识别',msg): - order_answer = {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'} if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg): order_answer = {'t':'收到您的图片,请描述一下您的问题。'} + if order_answer is None: + from .quote_scope import answer_us_fallback + order_answer = answer_us_fallback(msg,conv_id) ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None if ship and ship['region'] is None and (ship['weight'] is not None or ship['dims'] or re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or diff --git a/logistics_chat/customer_faq.py b/logistics_chat/customer_faq.py new file mode 100644 index 0000000..04fe189 --- /dev/null +++ b/logistics_chat/customer_faq.py @@ -0,0 +1,21 @@ +"""Describe supported customer actions without inventing operational integrations.""" + +import re + + +def answer_capability(message): + if re.search(r'照片|图片',message) and re.search(r'报价|价格|判断|识别|尺寸',message): + return {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'} + if re.search(r'(?:手机号|手机号码|电话号码).*(?:查|订单|运单)|(?:查|查询).*手机号',message): + return {'t':'当前请使用客户账号登录后,按入仓码查询订单;暂不支持手机号或短信验证码查单。没有入仓码可联系人工核实订单归属。'} + if re.search(r'下单(?!号|时间|日期)|订舱|生成订单|创建订单|预约提货|安排提货|\b(?:place|create)\s+(?:an?\s+)?order\b',message,re.I): + return {'t':'下单、订舱和安排提货需要联系人工办理。当前可查询报价及已关联订单,尚未创建订单或安排提货;请提供目的仓、品名、箱数、重量和尺寸供人工核对。'} + if re.search(r'开(?:发票|票)|发票.*(?:税点|几个点|怎么开|申请)|月结|账期|付款账号|收款账号|银行账号|打款|(?:怎么|如何)付款',message): + return {'t':'开票税点、月结资格及付款方式需要由专属业务员确认。请联系人工核对对应订单和结算信息;当前聊天不提供未经确认的税点或收款账号。'} + if re.search(r'导出.*(?:订单|运单|Excel)|(?:订单|运单).*导出',message,re.I): + return {'t':'当前聊天暂不提供订单Excel导出,可联系人工核对导出需求。可先查看账号内订单列表,或发送入仓码查询单票详情。'} + if re.search(r'Excel|表格',message,re.I) and re.search(r'直接读|自动读|读取|识别|读里面',message): + return {'t':'当前聊天不会自动读取Excel附件里的箱规。请将每箱重量、长宽高及目的仓码粘贴为文字,我可据此核算;批量表格可交人工整理。'} + if re.search(r'换.*手机|换.*设备|跨设备|清除.*(?:浏览器|缓存)',message) and re.search(r'聊天|记录|历史',message): + return {'t':'当前聊天记录与原浏览器的访客身份关联,换手机或清除浏览器数据后不会自动同步恢复。请先在原设备查看;客户账号目前用于订单权限校验,尚未提供跨设备聊天同步。'} + return None diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index 1e7a55d..3609ddd 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -219,44 +219,14 @@ def answer_count_query(intent, tenants): return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True} -def answer_list_query(message, tenants): - """Reuse bounded status parsing; list reads require an explicit customer scope.""" - if is_lookup_help(message) or not re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I): - return None - if not re.search(r'列出|列表|全部|所有|\blist\b',message,re.I): - return None - if not tenants: - return order_login_response() - question=re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I) - intent=parse_count_query(question) - if intent is None or intent['unsupported']: - return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'} - scope=tenant_filter(tenant_ids=tenants) - if scope is None: - return order_login_response() - from .db import connect - connection=None - try: - connection=connect() - sql='SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data WHERE 1=1'+scope[0] - values=scope[1] - if intent['status']: - sql+=' AND status=?';values=values+[intent['status']] - rows=connection.execute(sql+' ORDER BY id DESC LIMIT 21',values).fetchall() - if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True} - lines=['您当前的订单列表'+('(先列出20票)' if len(rows)>20 else '')+':'] - for number,inbound,status,warehouse in rows[:20]: - lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(status,status)};目的仓:{warehouse or "未登记"}') - lines.append('发送入仓码可查询该票订单和物流轨迹。') - return {'t':'\n'.join(lines),'_customer_data':True} - except sqlite3.Error: - return {'t':'订单信息暂时无法读取,请稍后重试。'} - finally: - if connection is not None:connection.close() +def answer_list_query(message, tenants, conv_id=None): + from .order_listing import answer_list + return answer_list(message,tenants,conv_id) def extract_reference(message): # Explicit postal/monetary fields are not order identifiers, even if invalid. + message = re.sub(r'(?:手机号|手机号码|电话号码|联系电话|电话)\s*[::是为]?\s*(?:\+?86[-\s]?)?1[3-9]\d{9}', ' ', message) message = re.sub(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*[0-9-]+', ' ', message, flags=re.I) message = re.sub(r'(? 1: @@ -422,6 +392,8 @@ def answer_query(message, conv_id): return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'} if detail_question(message) and not is_lookup_help(message): return order_login_response() if not tenants else {'t':'请提供入仓码,我先核对该票订单的实际物流记录。'} + if re.search(r'哪些.*(?:订单|运单)|(?:订单|运单).*(?:筛选|还没开船|未发货|已发货|未签收)',message) and not is_lookup_help(message): + return order_login_response() if not tenants else {'t':'暂不支持按这个物流条件批量筛选订单。可按业务状态查看列表,或提供入仓码逐票核对实际物流节点。'} if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|' r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|' r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|' diff --git a/logistics_chat/order_explanations.py b/logistics_chat/order_explanations.py index 9e27431..c1a7f91 100644 --- a/logistics_chat/order_explanations.py +++ b/logistics_chat/order_explanations.py @@ -7,7 +7,7 @@ def detail_question(message): if re.search(r'报价|运费|海派|海卡|\d+\s*(?:KG|公斤)',message,re.I) and not re.search(r'这票|该订单|我的订单',message): return False return bool(re.search(r'上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|' - r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|' + r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|改.*(?:收货|收件).*地址|(?:收货|收件)地址.*改|快递单号|付过款|付款状态|' r'(?:这票|该订单|我的货).*(?:发货|发走|出库|开船)',message)) @@ -24,6 +24,10 @@ def explain_order(message,row,card): lines=[f'入仓码 {reference}。' if row.get('inbound_code') else f'订单号 {reference}。'] if re.search(r'催单|催一下|拦截',message): return lines[0]+'催单或拦截需联系人工客服办理;当前仅查询订单,尚未提交催单或拦截申请。' + if re.search(r'付过款|付款状态',message): + return lines[0]+'当前订单未接入付款状态,不能根据业务状态判断是否付款,请联系人工核对账单。' + if '快递单号' in message: + return lines[0]+'当前未记录可核实的UPS/FedEx尾程快递单号,入仓码和内部订单号不能直接当作快递单号,请联系人工核对。' lines.append(f'登记的业务状态为“{card["status"]}”。') actual=card.get('nodes',[]) if card.get('node_kind')=='logistics' else [] if actual: diff --git a/logistics_chat/order_listing.py b/logistics_chat/order_listing.py new file mode 100644 index 0000000..be63119 --- /dev/null +++ b/logistics_chat/order_listing.py @@ -0,0 +1,69 @@ +"""Bounded conversational pagination over the server-validated customer scope.""" + +import re +import sqlite3 + +from .db import connect + + +def page_command(message): + text=re.sub(r'[\s,,。.!!??]','',message) + match=re.fullmatch(r'(?:请)?(?:订单)?第(\d{1,5})页',text) + if match:return int(match.group(1)) + if re.fullmatch(r'(?:请|继续)?(?:下一页|下页|继续显示|接着显示(?:剩下的)?订单)',text):return 'next' + if re.fullmatch(r'(?:请)?(?:上一页|上页)',text):return 'previous' + return None + + +def answer_list(message,tenants,conv_id=None): + from .order_data import is_lookup_help,parse_count_query,tenant_filter,STATUS_LABELS + from .customer_service import order_login_response + + command=page_command(message) + initial=(not is_lookup_help(message) and re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I) + and re.search(r'列出|列表|全部|所有|\blist\b',message,re.I)) + if command is None and not initial:return None + if not tenants:return order_login_response() + page=1;status=None + if command is None: + intent=parse_count_query(re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I)) + if intent is None or intent['unsupported']: + return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'} + status=intent['status'] + scope=tenant_filter(tenant_ids=tenants) + if scope is None:return order_login_response() + connection=None + try: + connection=connect() + if command is not None: + if not conv_id:return {'t':'请先在当前对话中发送“列出我的订单”,再翻页查看。'} + prior=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='assistant' ORDER BY id DESC LIMIT 1",(conv_id,)).fetchone() + header=prior[0].split('\n',1)[0] if prior else '' + match=re.fullmatch(r'您当前的订单列表(.*第(\d+)页;业务状态:([^)]+)):',header) + if not match:return {'t':'请先在当前对话中发送“列出我的订单”,再翻页查看。'} + labels={label:code for code,label in STATUS_LABELS.items()} + status=labels.get(match[2]) + if match[2]!='全部' and status is None:return {'t':'请重新指定需要查看的订单状态。'} + page=int(command) if isinstance(command,int) else max(1,int(match[1])+(1 if command=='next' else -1)) + if not 1<=page<=10000:return {'t':'请提供1至10000之间的有效页码。'} + where=' WHERE 1=1'+scope[0];values=scope[1] + if status:where+=' AND status=?';values=values+[status] + # Both reads see one current snapshot and apply exactly the same scope/filter. + connection.execute('BEGIN') + total=connection.execute('SELECT COUNT(*) FROM order_test_data'+where,values).fetchone()[0] + page=min(page,max(1,(total+19)//20)) + rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data'+where+ + ' ORDER BY id DESC LIMIT 20 OFFSET ?',values+[(page-1)*20]).fetchall() + if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True} + label=STATUS_LABELS[status] if status else '全部' + first='先列出20票;' if page==1 and total>20 else '' + lines=[f'您当前的订单列表({first}第{page}页;业务状态:{label}):',f'共{total}票,当前显示第{(page-1)*20+1}至{(page-1)*20+len(rows)}票。'] + for number,inbound,business_status,warehouse in rows: + lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(business_status,business_status)};目的仓:{warehouse or "未登记"}') + lines.append('回复“下一页”查看后续订单。' if page*20=-6: + return exact_number(value) + return '约'+number(value,6) + + def render_rates(intent, book, destinations, remote_fees): from .quote_fees import charge_details from .quote_language import translate_quote @@ -124,12 +135,14 @@ def render_rates(intent, book, destinations, remote_fees): if intent.get('mode')=='time': lines.append('美国线路参考时效:') elif intent['weight']: - lines.append(f"美国 {number(intent['weight'])}KG 基础运费:") + lines.append(f"美国 {exact_number(intent['weight'])}KG 基础运费:") else: lines.append('美国线路报价:') if intent.get('quantity_note'):lines.append(intent['quantity_note']) if intent.get('allocations'): lines.append('按各目的仓分票计算,箱规需逐票核对:') + if intent.get('metal_chargeable_weight') is not None: + lines.append('请分别提供各目的仓的铁铝货物计费重,整票铁铝基数不能重复套用到每个仓。') elif len(intent.get('warehouses') or [])>1: lines.append('以下按各目的仓分别比较;实际分票请提供每个仓的重量。') if intent.get('air_requested') and not intent.get('air_only'): @@ -138,6 +151,8 @@ def render_rates(intent, book, destinations, remote_fees): part=destination.get('allocation') billing=({**intent,'weight':part['weight'],'packages':part.get('packages'),'dims':None,'cbm':None,'boxes':part.get('boxes')} if part else intent) + if part and intent.get('metal_chargeable_weight') is not None: + billing.update(metal_chargeable_weight=None,metal_scope='partial') rates = match_rates(billing, book, destination) if intent.get('mode') in ('cheapest','fastest') and rates: if intent['mode']=='cheapest': @@ -181,7 +196,7 @@ def render_rates(intent, book, destinations, remote_fees): total = amount*price details=charge_details(billing,rate,fees,book) total+=details['cny'] - rate_text += f",{number(amount,3)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元" + rate_text += f",{quantity_number(amount,billing)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元" if details['items']:rate_text+='('+ ';'.join(details['items'])+')' for note in details['notes']: if note not in lines:lines.append(note) diff --git a/logistics_chat/quote_fees.py b/logistics_chat/quote_fees.py index ca0e65d..1bcc0a5 100644 --- a/logistics_chat/quote_fees.py +++ b/logistics_chat/quote_fees.py @@ -22,6 +22,35 @@ def material_fee(book): return amount(text,r'铁铝制品[^\n]*?[¥¥]\s*(\d+(?:\.\d+)?)\s*/KG') +def material_charge(intent,charged,unit,book): + result={'cny':Decimal(0),'items':[],'notes':[]} + material=intent.get('material') + if material not in ('metal','unknown_metal'):return result + per_kg=material_fee(book) + if per_kg is None: + result['notes'].append('铁铝附加费费率尚未明确,当前金额未包含该项,请联系人工确认。') + return result + if material=='unknown_metal': + result['notes'].append('请先确认金属是否属于铁铝制品;铁铝附加费只按铁铝货物的计费重收取,当前金额未包含待核实的材质费用。') + return result + basis=intent.get('metal_chargeable_weight') + if basis is None and intent.get('metal_scope')=='partial': + result['notes'].append(f'铁铝附加费{per_kg:g}元/KG只按铁铝货物的计费重收取,不能按整票重量加费;请提供铁铝部分的计费重,当前金额未包含该项。') + return result + if basis is None and unit!='kg': + result['notes'].append(f'铁铝附加费为{per_kg:g}元/KG,当前按方运费未包含该项;需确认铁铝货物计费重量后单列,不能把CBM直接乘公斤附加费。') + return result + basis=Decimal(basis) if basis is not None else charged + if basis is None:return result + if unit=='kg' and charged is not None and basis>charged: + result['notes'].append('铁铝货物计费重高于当前整票可计算的计费重,请补充各箱实重和尺寸核对;当前金额未包含该项。') + return result + fee=basis*per_kg + result['cny']=fee + result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{basis:g}KG={fee:g}元') + return result + + def specimens(intent): if intent.get('packages'): return [{**p,'dims':p.get('dims') or intent.get('dims'), @@ -83,13 +112,8 @@ def charge_details(intent,rate,remote,book): fee=charged*Decimal(remote['fee']);result['cny']+=fee result['items'].append(f'偏远费{remote["fee"]}元/KG×{charged:g}KG={fee:g}元') else:result['notes'].append('遥远地址的附加费需单独确认,当前金额未包含该项。') - if intent.get('material')=='metal': - per_kg=material_fee(book) - if per_kg is not None and rate['unit']=='kg': - fee=charged*per_kg;result['cny']+=fee - result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{charged:g}KG={fee:g}元') - elif per_kg is not None: - result['notes'].append(f'铁铝附加费为{per_kg:g}元/KG,当前按方运费未包含该项;需确认适用计费重量后单列,不能把CBM直接乘公斤附加费。') + metal=material_charge(intent,charged,rate['unit'],book) + result['cny']+=metal['cny'];result['items']+=metal['items'];result['notes']+=metal['notes'] if rate['kind']=='parcel': limits=limit_fees(intent,book) result['cny']+=limits['cny'];result['items']+=limits['items'];result['notes']+=limits['notes'] diff --git a/logistics_chat/quote_intent.py b/logistics_chat/quote_intent.py index 8aaeb66..fb436b7 100644 --- a/logistics_chat/quote_intent.py +++ b/logistics_chat/quote_intent.py @@ -8,7 +8,7 @@ from .pricing import parse_shipping_intent, detect_dim_unit, shipping_region US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|' r'(?1:parsed['dims']=None elif assigned: weight=str(sum(Decimal(a['weight']) for a in assigned)) if set(a['warehouse'] for a in assigned)!=set(warehouses):errors.append('请分别提供每个目的仓的重量,避免遗漏分票。') - total=re.search(r'(?:总重|合计重量)\s*'+WEIGHT.pattern,text,re.I) + total=re.search(r'(?:总重|合计重量)\s*'+WEIGHT.pattern,quantity_text,re.I) if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与各目的仓分配重量不一致,请确认。') - elif len(weights)>1: + elif len(weights)>1 and request_mode(text)!='rounding': errors.append('请明确总重,或分别提供每箱重量,避免把不同重量混在一起。') if assigned and packages and Decimal(weight)!=sum(Decimal(a['weight']) for a in assigned): errors.append('分仓重量与分箱重量不一致,请分别确认每仓的箱数和重量。') @@ -149,8 +162,8 @@ def parse_intent(message,warehouse_codes): 'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None, 'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None), 'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None), - 'material':('ordinary' if re.search(r'普通货|普货|塑料|木制|非铁铝',text) else - ('metal' if re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text) else None)), + 'material':materials['material'],'metal_scope':materials['metal_scope'], + 'metal_chargeable_weight':materials['metal_chargeable_weight'], 'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)), 'preserve_total':True if re.search(r'总重不变|重量不变',text) else None,'text':text} @@ -166,4 +179,4 @@ def is_followup(message,intent): if intent.get('mode')!='quote':return not (intent['country'] and intent['weight']) if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓)?(?:呢)?',text):return True - return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin'))) + return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin','zone','material','metal_scope','metal_chargeable_weight'))) diff --git a/logistics_chat/quote_material.py b/logistics_chat/quote_material.py new file mode 100644 index 0000000..83927ad --- /dev/null +++ b/logistics_chat/quote_material.py @@ -0,0 +1,30 @@ +"""Separate metal chargeable-weight declarations from the shipment's gross weight.""" + +import re +from decimal import Decimal + + +def material_intent(text,weight_pattern,to_kg): + declarations=re.sub(r'铁铝(?:附加费|加费|费用)','',text) + metal=bool(re.search(r'铁制|钢制|铝制|铁铝|不锈钢|(?:铁|钢|铝)(?:件|货|扣|罐|盒|板|管|架|材)',declarations)) + generic='金属' in text + ordinary=bool(re.search(r'普通货|普货|非铁铝',text)) + partial=bool(re.search(r'只有|部分|其中|有些|只是|一部分',text) or + metal and re.search(r'塑料|木制|布料',text)) + scope='partial' if partial and (metal or generic) else ('all' if re.search(r'全是|全部是|都是|整票是|货物是|换成|改成',text) and metal else None) + material=('ordinary' if '非铁铝' in text else 'metal' if metal else 'unknown_metal' if generic + else 'ordinary' if ordinary or re.search(r'塑料|木制',text) else None) + name=r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)(?:部分|货物)?(?:的)?' + patterns=(name+r'\s*计费重(?:量)?\s*(?:为|是|[::])?\s*'+weight_pattern.pattern, + name+r'\s*按\s*'+weight_pattern.pattern+r'\s*计费') + charged=None;cleaned=text;errors=[] + for pattern in patterns: + matches=list(re.finditer(pattern,text,re.I)) + for match in matches: + value=to_kg(*match.groups()) + if charged is not None and Decimal(charged)!=value:errors.append('铁铝货物的计费重不一致,请确认。') + charged=str(value);scope='partial' + if not 0