"""Local, editable order test data. This repository can later be replaced by MCP.""" import json import re import sqlite3 from datetime import datetime from .config import get_setting from .order_tracking import build_tracking from .visitor_service import customer_tenant_ids from .customer_service import order_login_response FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id', 'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role', 'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm', 'history_json', 'tracking_json', 'exception_reason', 'data_note', 'source_updated_at', 'snapshot_at') STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过', 'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中', 'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'} FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipment ID', 'country':'目的国', 'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数', 'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级', 'parent':'父订单号', 'updated':'来源更新时间'} IDENTIFIER = re.compile(r'(?2000: raise ValueError('exception_reason must be customer-visible text up to 2000 characters') row['exception_reason']=row['exception_reason'].strip() or None history = row['history_json'] or [] if isinstance(history, str): history = json.loads(history) if not isinstance(history, list): raise ValueError('history_json must contain a list') row['history_json'] = json.dumps(history, ensure_ascii=False) tracking = row['tracking_json'] or [] if isinstance(tracking, str): tracking = json.loads(tracking) if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list') row['tracking_json'] = json.dumps(tracking, ensure_ascii=False) row['snapshot_at'] = row['snapshot_at'] or datetime.now().isoformat(timespec='seconds') exists = connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?', (row['tenant_id'], row['order_no'])).fetchone() if exists and not replace: skipped += 1 continue columns = ','.join(FIELDS) placeholders = ','.join('?' for _ in FIELDS) updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no') and (key!='exception_reason' or key in source)) connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) ' f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS]) inserted += 1 return {'imported': inserted, 'preserved': skipped} def import_tracking_records(connection, records, replace=False): """Update tracking alone; never replace hand-edited order fields or add unknown orders.""" init_order_table(connection) imported = preserved = missing = 0 for record in records: if not isinstance(record, dict) or not record.get('tenant_id') or not record.get('order_no'): raise ValueError('Tracking record requires tenant_id and order_no') tracking = record.get('tracking_json') if isinstance(tracking, str): tracking = json.loads(tracking) if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list') row = connection.execute('SELECT id,source_order_id,tracking_json FROM order_test_data ' 'WHERE tenant_id=? AND order_no=?', (str(record['tenant_id']), record['order_no'])).fetchone() if not row: missing += 1 continue if not record.get('source_order_id') or str(record['source_order_id']) != str(row[1]): raise ValueError('Tracking source_order_id does not match local order') if not replace and row[2] not in (None, '', '[]'): preserved += 1 continue connection.execute('UPDATE order_test_data SET tracking_json=? WHERE id=?', (json.dumps(tracking, ensure_ascii=False), row[0])) imported += 1 return {'imported': imported, 'preserved': preserved, 'missing': missing} def tenant_filter(tenant_id=None, tenant_ids=None): """Share the same server-side scope intersection for exact and aggregate reads.""" tenant = get_setting('ORDER_DATA_TENANT_ID') if tenant_ids is not None: if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256: return None allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128} if tenant_id is not None: allowed.intersection_update({str(tenant_id)}) if tenant: allowed.intersection_update({str(tenant)}) return (' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')', sorted(allowed)) if allowed else None if tenant_id is not None: if not str(tenant_id).strip() or (tenant and str(tenant_id) != str(tenant)): return None tenant = str(tenant_id) return (' AND tenant_id=?', [str(tenant)]) if tenant else ('', []) def lookup_order(reference, tenant_id=None, tenant_ids=None): """Exact, bounded lookup; scope comes from server configuration, never model SQL.""" from .db import connect scope = tenant_filter(tenant_id, tenant_ids) if scope is None: return [] connection = None try: connection = connect() connection.row_factory = sqlite3.Row query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' + scope[0] values = [reference] * 3 + scope[1] return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)] except sqlite3.Error: raise OrderDataUnavailable from None finally: if connection is not None: connection.close() def count_orders(tenant_ids, status=None): """Fresh totals require an explicit authorized scope; never fall back to all rows.""" from .db import connect empty = {'total':0, 'with_inbound':0} scope = tenant_filter(tenant_ids=tenant_ids if tenant_ids is not None else ()) if scope is None or (status is not None and status not in STATUS_LABELS): return empty connection = None try: connection = connect() query = ("SELECT COUNT(*),COUNT(NULLIF(TRIM(inbound_code,char(9)||char(10)||char(13)||' '),'')) " 'FROM order_test_data WHERE 1=1' + scope[0]) values = scope[1] if status is not None: query += ' AND status=?' values = values + [status] total, with_inbound = connection.execute(query,values).fetchone() return {'total':total, 'with_inbound':with_inbound} except sqlite3.Error: raise OrderDataUnavailable from None finally: if connection is not None: connection.close() def parse_count_query(message): if is_lookup_help(message):return None from .order_query import parse_query return parse_query(message) def answer_count_query(intent, tenants,conv_id=None): from .order_listing import answer_summary return answer_summary(intent,tenants,conv_id) 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: return {'t':'请一次提供一个入仓码,避免查错订单。'} reference = references[0] if references else None if not reference and conv_id and is_order_followup(message): from .db import connect from .customer_faq import is_acknowledgement connection = connect() try: recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC', (conv_id,)) for role, content in recent: if role == 'user': if is_acknowledgement(content):continue earlier_references = extract_reference(content) if earlier_references: reference = earlier_references[0] if len(earlier_references) == 1 else None break if not is_order_followup(content): break if role == 'assistant' and content.startswith('__CARD__'): try: previous = json.loads(content[8:]) if previous.get('kind') == 'order': reference = previous['no'] break except (ValueError, KeyError, TypeError): pass finally: connection.close() if not reference: if mutation: 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]*$|' r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b|' r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I)): return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'} return None if not tenants: return order_login_response() try: matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants) except OrderDataUnavailable: return {'t':'订单信息暂时无法读取,请稍后重试。'} if not matches: return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'} if len(matches) > 1: return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'} if mutation: return {'t':f'已查到订单“{reference}”。当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。','_customer_data':True} if re.search(r'运费|费用|多少钱|价格', message): return {'t':'该订单未记录实际运费,请联系人工客服核实。','_customer_data':True} card=build_card(matches[0]) explanation=explain_order(message,matches[0],card) if explanation:return {'t':explanation,'_customer_data':True} return {'card':card}