feat: 重构订单和物流信息展示格式

主要改动:
- 订单列表:使用 order_list 格式,展示 5 个订单(全部状态)
- 订单详情:使用 order_detail 格式,优化价格和时间显示
- 物流信息:使用 logistics 格式,根据 track id 动态生成步骤
- 商品图片:从 orderProduct.imageUrl 字段获取
- 时间格式:统一为 YYYY-MM-DD HH:MM:SS
- 多语言支持:amountLabel、orderTime 支持中英文
- 配置管理:新增 FRONTEND_URL 环境变量
- API 集成:改进 Mall API tracks 数据解析
- 认证优化:account_id 从 webhook 动态获取

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wangliang
2026-01-23 18:49:40 +08:00
parent e8e89601a5
commit 0b5d0a8086
11 changed files with 1493 additions and 394 deletions

View File

@@ -14,6 +14,146 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# 订单字段多语言映射
ORDER_FIELD_LABELS = {
"zh": { # 中文
"title": "订单详情",
"order_id": "订单号",
"status": "订单状态",
"total_amount": "订单金额",
"payment_method": "支付方式",
"items_count": "商品总数量",
"shipping_method": "运输方式",
"tracking_number": "运单号",
"shipping_address": "收货地址",
"billing_address": "发票地址",
"channel": "渠道",
"created_at": "下单时间",
"action": "操作",
},
"en": { # English
"title": "Order Details",
"order_id": "Order ID",
"status": "Order Status",
"total_amount": "Total Amount",
"payment_method": "Payment Method",
"items_count": "Total Items",
"shipping_method": "Shipping Method",
"tracking_number": "Tracking Number",
"shipping_address": "Shipping Address",
"billing_address": "Billing Address",
"channel": "Channel",
"created_at": "Order Date",
"action": "Action",
},
"nl": { # Dutch (荷兰语)
"title": "Orderdetails",
"order_id": "Ordernummer",
"status": "Orderstatus",
"total_amount": "Totaalbedrag",
"payment_method": "Betaalmethode",
"items_count": "Totaal aantal artikelen",
"shipping_method": "Verzendmethode",
"tracking_number": "Trackingsnummer",
"shipping_address": "Verzendadres",
"billing_address": "Factuuradres",
"channel": "Kanaal",
"created_at": "Besteldatum",
"action": "Actie",
},
"de": { # German (德语)
"title": "Bestelldetails",
"order_id": "Bestellnummer",
"status": "Bestellstatus",
"total_amount": "Gesamtbetrag",
"payment_method": "Zahlungsmethode",
"items_count": "Gesamtanzahl Artikel",
"shipping_method": "Versandart",
"tracking_number": "Sendungsnummer",
"shipping_address": "Lieferadresse",
"billing_address": "Rechnungsadresse",
"channel": "Kanal",
"created_at": "Bestelldatum",
"action": "Aktion",
},
"es": { # Spanish (西班牙语)
"title": "Detalles del pedido",
"order_id": "Número de pedido",
"status": "Estado del pedido",
"total_amount": "Importe total",
"payment_method": "Método de pago",
"items_count": "Total de artículos",
"shipping_method": "Método de envío",
"tracking_number": "Número de seguimiento",
"shipping_address": "Dirección de envío",
"billing_address": "Dirección de facturación",
"channel": "Canal",
"created_at": "Fecha del pedido",
"action": "Acción",
},
"fr": { # French (法语)
"title": "Détails de la commande",
"order_id": "Numéro de commande",
"status": "Statut de la commande",
"total_amount": "Montant total",
"payment_method": "Méthode de paiement",
"items_count": "Nombre total d'articles",
"shipping_method": "Méthode d'expédition",
"tracking_number": "Numéro de suivi",
"shipping_address": "Adresse de livraison",
"billing_address": "Adresse de facturation",
"channel": "Canal",
"created_at": "Date de commande",
"action": "Action",
},
"it": { # Italian (意大利语)
"title": "Detagli dell'ordine",
"order_id": "Numero ordine",
"status": "Stato ordine",
"total_amount": "Importo totale",
"payment_method": "Metodo di pagamento",
"items_count": "Totale articoli",
"shipping_method": "Metodo di spedizione",
"tracking_number": "Numero di tracciamento",
"shipping_address": "Indirizzo di spedizione",
"billing_address": "Indirizzo di fatturazione",
"channel": "Canale",
"created_at": "Data ordine",
"action": "Azione",
},
"tr": { # Turkish (土耳其语)
"title": "Sipariş Detayları",
"order_id": "Sipariş Numarası",
"status": "Sipariş Durumu",
"total_amount": "Toplam Tutar",
"payment_method": "Ödeme Yöntemi",
"items_count": "Toplam Ürün",
"shipping_method": "Kargo Yöntemi",
"tracking_number": "Takip Numarası",
"shipping_address": "Teslimat Adresi",
"billing_address": "Fatura Adresi",
"channel": "Kanal",
"created_at": "Sipariş Tarihi",
"action": "İşlem",
},
}
def get_field_label(field_key: str, language: str = "en") -> str:
"""获取指定语言的字段标签
Args:
field_key: 字段键名(如 "order_id", "status" 等)
language: 语言代码(默认 "en"
Returns:
对应语言的字段标签
"""
if language not in ORDER_FIELD_LABELS:
language = "en" # 默认使用英文
return ORDER_FIELD_LABELS[language].get(field_key, ORDER_FIELD_LABELS["en"].get(field_key, field_key))
class MessageType(str, Enum):
"""Chatwoot message types"""
INCOMING = "incoming"
@@ -91,7 +231,49 @@ class ChatwootClient:
if self._client:
await self._client.aclose()
self._client = None
def _format_datetime(self, datetime_str: str) -> str:
"""格式化日期时间为 YYYY-MM-DD HH:MM:SS 格式
Args:
datetime_str: 输入的日期时间字符串
Returns:
格式化后的日期时间字符串 (YYYY-MM-DD HH:MM:SS)
"""
if not datetime_str:
return ""
# 如果已经是正确的格式YYYY-MM-DD HH:MM:SS直接返回
if len(datetime_str) == 19 and datetime_str[10] == ' ' and datetime_str[13] == ':' and datetime_str[16] == ':':
return datetime_str
# 尝试解析常见的时间格式
from datetime import datetime
# 常见格式列表
formats = [
"%Y-%m-%d %H:%M:%S", # 2025-01-23 14:30:00
"%Y-%m-%dT%H:%M:%S", # 2025-01-23T14:30:00
"%Y-%m-%d %H:%M:%S.%f", # 2025-01-23 14:30:00.123456
"%Y-%m-%dT%H:%M:%S.%f", # 2025-01-23T14:30:00.123456
"%Y-%m-%dT%H:%M:%SZ", # 2025-01-23T14:30:00Z
"%Y-%m-%dT%H:%M:%S.%fZ", # 2025-01-23T14:30:00.123456Z
"%Y/%m/%d %H:%M:%S", # 2025/01/23 14:30:00
"%d-%m-%Y %H:%M:%S", # 23-01-2025 14:30:00
"%m/%d/%Y %H:%M:%S", # 01/23/2025 14:30:00
]
for fmt in formats:
try:
dt = datetime.strptime(datetime_str, fmt)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
continue
# 如果无法解析,返回原始字符串
return datetime_str
# ============ Messages ============
async def send_message(
@@ -283,28 +465,22 @@ class ChatwootClient:
self,
conversation_id: int,
order_data: dict[str, Any],
language: str = "en",
actions: Optional[list[dict[str, Any]]] = None
) -> dict[str, Any]:
"""发送订单详情表单消息(使用 content_type=form
根据 Chatwoot API 文档实现的 form 格式订单详情展示。
form 类型支持的字段类型text, text_area, email, select
"""发送订单详情(使用 content_type=order_detail
Args:
conversation_id: 会话 ID
order_data: 订单数据,包含:
- order_id: 订单号
- status: 订单状态
- status: 订单状态
- status_text: 状态文本
- created_at: 下单时间(可选)
- items: 商品列表(可选)
- created_at: 下单时间
- total_amount: 总金额
- shipping_fee: 运费(可选)
- logistics: 物流信息(可选)
- remark: 备注(可选
actions: 操作按钮配置列表(可选),每个按钮包含:
- label: 按钮文字(用于 select 选项的显示)
- value: 按钮值(用于 select 选项的值)
- items: 商品列表
language: 语言代码en, nl, de, es, fr, it, tr, zh默认 en
actions: 操作按钮配置列表(可选,暂未使用
Returns:
发送结果
@@ -312,131 +488,461 @@ class ChatwootClient:
Example:
>>> order_data = {
... "order_id": "123456789",
... "status": "shipped",
... "status_text": "发货",
... "created_at": "2023-10-27 14:30",
... "total_amount": "1058.00",
... "items": [{"name": "商品A", "quantity": 2, "price": "100.00"}]
... "status": "completed",
... "status_text": "完成",
... "created_at": "2025-01-23 14:30:00",
... "total_amount": "179.97",
... "items": [{"name": "商品A", "image_url": "...", "quantity": 2, "price": "49.99"}]
... }
>>> actions = [
... {"label": "查看详情", "value": "VIEW_DETAILS"},
... {"label": "联系客服", "value": "CONTACT_SUPPORT"}
... ]
>>> await chatwoot.send_order_form(123, order_data, actions)
>>> await chatwoot.send_order_form(123, order_data, language="zh")
"""
# 构建表单字段
form_items = []
order_id = order_data.get("order_id", "")
status = order_data.get("status", "")
status_text = order_data.get("status_text", "")
# 订单号(只读文本)
form_items.append({
"name": "order_id",
"label": "订单号",
"type": "text",
"placeholder": "订单号",
"default": order_data.get("order_id", "")
})
# 获取原始时间并格式化为 YYYY-MM-DD HH:MM:SS
raw_created_at = order_data.get("created_at", order_data.get("date_added", ""))
created_at = self._format_datetime(raw_created_at)
# 订单状态(只读文本)
status_text = order_data.get("status_text", order_data.get("status", "unknown"))
form_items.append({
"name": "status",
"label": "订单状态",
"type": "text",
"placeholder": "订单状态",
"default": status_text
})
total_amount = order_data.get("total_amount", "0")
# 下单时间(只读文本)
if order_data.get("created_at"):
form_items.append({
"name": "created_at",
"label": "下单时间",
"type": "text",
"placeholder": "下单时间",
"default": order_data["created_at"]
})
# 商品列表(多行文本)
items = order_data.get("items", [])
if items:
items_text = "\n".join([
f"▫️ {item.get('name', '未知商品')} × {item.get('quantity', 1)} - ¥{item.get('price', '0.00')}"
for item in items
])
form_items.append({
"name": "items",
"label": "商品详情",
"type": "text_area",
"placeholder": "商品列表",
"default": items_text
})
# 总金额(只读文本)
form_items.append({
"name": "total_amount",
"label": "总金额",
"type": "text",
"placeholder": "总金额",
"default": f"¥{order_data.get('total_amount', '0.00')}"
})
# 运费(只读文本)
if order_data.get("shipping_fee") is not None:
form_items.append({
"name": "shipping_fee",
"label": "运费",
"type": "text",
"placeholder": "运费",
"default": f"¥{order_data['shipping_fee']}"
})
# 物流信息(多行文本)
logistics = order_data.get("logistics")
if logistics:
logistics_text = (
f"承运商: {logistics.get('carrier', '未知')}\n"
f"单号: {logistics.get('tracking_number', '未知')}"
)
form_items.append({
"name": "logistics",
"label": "物流信息",
"type": "text_area",
"placeholder": "物流信息",
"default": logistics_text
})
# 备注(多行文本)
if order_data.get("remark"):
form_items.append({
"name": "remark",
"label": "备注",
"type": "text_area",
"placeholder": "备注",
"default": order_data["remark"]
})
# 操作选项(下拉选择,如果提供了 actions
if actions:
form_items.append({
"name": "action_select",
"label": "操作",
"type": "select",
"options": actions
})
# 构建 content_attributes
content_attributes = {
"items": form_items
# 根据状态码映射状态和颜色
status_mapping = {
"0": {"status": "cancelled", "text": "已取消", "color": "text-red-600"},
"1": {"status": "pending", "text": "待支付", "color": "text-yellow-600"},
"2": {"status": "paid", "text": "已支付", "color": "text-blue-600"},
"3": {"status": "shipped", "text": "已发货", "color": "text-purple-600"},
"4": {"status": "signed", "text": "已签收", "color": "text-green-600"},
"15": {"status": "completed", "text": "已完成", "color": "text-green-600"},
"100": {"status": "cancelled", "text": "超时取消", "color": "text-red-600"},
}
# 发送 form 类型消息
return await self.send_rich_message(
status_info = status_mapping.get(str(status), {"status": "unknown", "text": status_text or "未知", "color": "text-gray-600"})
# 构建商品列表
items = order_data.get("items", [])
formatted_items = []
for item in items:
# 获取价格并格式化为字符串(包含货币符号)
price_value = item.get("price", "0")
try:
price_num = float(price_value)
price_text = f"{price_num:.2f}"
except (ValueError, TypeError):
price_text = str(price_value) if price_value else "€0.00"
formatted_items.append({
"name": item.get("name", "未知商品"),
"quantity": int(item.get("quantity", 1)),
"price": price_text,
"image": item.get("image_url", "")
})
# 计算商品总数量
total_quantity = sum(item.get("quantity", 1) for item in items)
# 计算总金额并格式化(多语言)
try:
total_amount_value = float(total_amount)
except (ValueError, TypeError):
total_amount_value = 0.0
# 构建总计标签
if language == "zh":
total_label = f"{total_quantity} 件商品"
amount_text = f"总计: €{total_amount_value:.2f}"
order_time_text = f"下单时间: {created_at}"
else:
total_label = f"Total: {total_quantity} items"
amount_text = f"Total: €{total_amount_value:.2f}"
order_time_text = f"Order time: {created_at}"
# 构建操作按钮
actions_list = []
# 获取前端 URL
frontend_url = settings.frontend_url.rstrip('/')
if language == "zh":
# 中文按钮
actions_list.append({
"text": "查看物流",
"style": "default",
"reply": f"查看订单物流信息:{order_id}"
})
actions_list.append({
"text": "订单详情",
"style": "primary",
"url": f"{frontend_url}/customer/order/detail?orderId={order_id}",
"target": "_self"
})
else:
# 英文按钮
actions_list.append({
"text": "Logistics info",
"style": "default",
"reply": f"查看订单物流信息:{order_id}"
})
actions_list.append({
"text": "Order details",
"style": "primary",
"url": f"{frontend_url}/customer/order/detail?orderId={order_id}",
"target": "_self"
})
# 构建 content_attributesorder_detail 格式)
content_attributes = {
"status": status_info["status"],
"statusText": status_info["text"],
"statusColor": status_info["color"],
"orderId": str(order_id),
"orderTime": order_time_text,
"items": formatted_items,
"showTotal": True,
"totalLabel": total_label,
"amountLabel": amount_text,
"actions": actions_list
}
# 记录发送的数据(用于调试)
logger.info(
"Sending order detail",
conversation_id=conversation_id,
content="订单详情",
content_type="form",
content_attributes=content_attributes
order_id=order_id,
items_count=len(formatted_items),
total_quantity=total_quantity,
language=language
)
# 发送 order_detail 类型消息
client = await self._get_client()
payload = {
"content_type": "order_detail",
"content": "",
"content_attributes": content_attributes
}
response = await client.post(
f"/conversations/{conversation_id}/messages",
json=payload
)
response.raise_for_status()
return response.json()
async def send_logistics_info(
self,
conversation_id: int,
logistics_data: dict[str, Any],
language: str = "en"
) -> dict[str, Any]:
"""发送物流信息(使用 content_type=logistics
Args:
conversation_id: 会话 ID
logistics_data: 物流数据,包含:
- carrier: 物流公司名称
- tracking_number: 运单号
- status: 当前状态
- timeline: 物流轨迹列表
- order_id: 订单号(可选,用于生成链接)
language: 语言代码en, nl, de, es, fr, it, tr, zh默认 en
Returns:
发送结果
Example:
>>> logistics_data = {
... "carrier": "顺丰速运",
... "tracking_number": "SF154228901",
... "status": "派送中",
... "timeline": [...]
... }
>>> await chatwoot.send_logistics_info(123, logistics_data, language="zh")
"""
carrier = logistics_data.get("carrier", "")
tracking_number = logistics_data.get("tracking_number", "")
status = logistics_data.get("status", "")
timeline = logistics_data.get("timeline", [])
order_id = logistics_data.get("order_id", "")
# 获取最新物流信息(从 timeline 中提取 remark
latest_log = ""
latest_time = ""
current_step = 0
# Track ID 到步骤的映射
# id = 1 -> Order Received已接单
# id = 10 -> Picked Up已揽收
# id = 20 -> In Transit运输中
# id = 30 -> Delivered已送达
track_id_to_step = {
"1": 0, # Order Received
"10": 1, # Picked Up
"20": 2, # In Transit
"30": 3 # Delivered
}
if timeline and len(timeline) > 0:
# 获取第一条(最新)物流信息
first_event = timeline[0]
if isinstance(first_event, dict):
# 提取 remark 字段
remark = first_event.get("remark", "")
# 提取时间
time_str = (
first_event.get("time") or
first_event.get("date") or
first_event.get("timestamp") or
""
)
# 提取位置
location = first_event.get("location", "")
# 提取 track id
track_id = str(first_event.get("id", ""))
# 构建最新物流描述:如果 remark 为空,则只显示 location否则显示 location | remark
if location and remark:
latest_log = f"{location} | {remark}"
elif remark:
latest_log = remark
elif location:
latest_log = location
else:
latest_log = ""
# 格式化时间
if time_str:
latest_time = self._format_datetime(str(time_str))
# 根据 track id 判断当前步骤
if track_id in track_id_to_step:
current_step = track_id_to_step[track_id]
else:
# 如果无法识别 id根据时间线长度判断
current_step = len(timeline) if len(timeline) <= 4 else 2
# 构建步骤列表固定4个步骤
if language == "zh":
steps = [
{"label": "已接单"},
{"label": "已揽收"},
{"label": "运输中"},
{"label": "已送达"}
]
else:
steps = [
{"label": "Order Received"},
{"label": "Picked Up"},
{"label": "In Transit"},
{"label": "Delivered"}
]
# 构建操作按钮
actions = []
frontend_url = settings.frontend_url.rstrip('/')
if order_id:
# 如果有订单号,生成物流追踪链接
if language == "zh":
actions.append({
"text": "物流详情",
"style": "primary",
"url": f"{frontend_url}/logistic-tracking/{order_id}"
})
else:
actions.append({
"text": "Tracking Details",
"style": "primary",
"url": f"{frontend_url}/logistic-tracking/{order_id}"
})
# 构建 content_attributeslogistics 格式)
content_attributes = {
"logisticsName": carrier,
"trackingNumber": tracking_number,
"currentStep": current_step,
"isUrgent": False,
"latestLog": latest_log,
"latestTime": latest_time,
"steps": steps,
"actions": actions
}
# 记录发送的数据(用于调试)
logger.info(
"Sending logistics info",
conversation_id=conversation_id,
carrier=carrier,
tracking_number=tracking_number,
current_step=current_step,
timeline_count=len(timeline) if timeline else 0,
language=language
)
# 发送 logistics 类型消息
client = await self._get_client()
payload = {
"content_type": "logistics",
"content": "",
"content_attributes": content_attributes
}
response = await client.post(
f"/conversations/{conversation_id}/messages",
json=payload
)
response.raise_for_status()
return response.json()
async def send_order_list(
self,
conversation_id: int,
orders: list[dict[str, Any]],
language: str = "en"
) -> dict[str, Any]:
"""发送订单列表(使用自定义 content_type=order_list
每个订单包含:
- orderNumber: 订单号
- date: 订单日期
- status: 订单状态
- items: 商品列表(图片和名称)
- actions: 操作按钮
Args:
conversation_id: 会话 ID
orders: 订单列表,每个订单包含:
- order_id: 订单号
- status_text: 订单状态
- created_at: 下单时间
- items: 商品列表(可选,包含 name 和 image_url
- order_type: 订单类型(可选,用于判断渠道)
language: 语言代码en, nl, de, es, fr, it, tr, zh默认 en
Returns:
发送结果
Example:
>>> orders = [
... {
... "order_id": "20250122001",
... "created_at": "Jan 22, 2025",
... "status_text": "Shipped",
... "items": [
... {"image_url": "url1", "name": "Product 1"},
... {"image_url": "url2", "name": "Product 2"}
... ]
... }
... ]
>>> await chatwoot.send_order_list(123, orders, language="en")
"""
# 获取多语言按钮文本
if language == "zh":
details_text = "订单详情"
logistics_text = "物流信息"
details_reply_prefix = "查看订单详情:"
logistics_reply_prefix = "查看订单物流信息:"
else:
details_text = "Order details"
logistics_text = "Logistics info"
details_reply_prefix = "查看订单详情:"
logistics_reply_prefix = "查看订单物流信息:"
# 构建订单列表
order_list_data = []
for order in orders:
order_id = order.get("order_id", "")
status_text = order.get("status_text", "")
# 获取原始时间并格式化为 YYYY-MM-DD HH:MM:SS
raw_created_at = order.get("created_at", order.get("date_added", ""))
created_at = self._format_datetime(raw_created_at)
# 构建商品列表
items = order.get("items", [])
formatted_items = []
logger.info(
f"Processing order {order_id} for items",
items_count=len(items) if isinstance(items, list) else 0,
items_type=type(items).__name__,
first_item_keys=list(items[0].keys()) if items and len(items) > 0 else None
)
if items and isinstance(items, list):
for item in items:
formatted_items.append({
"image": item.get("image_url", ""),
"name": item.get("name", "Product")
})
logger.info(
f"Formatted items for order {order_id}",
formatted_items_count=len(formatted_items),
sample_items=str(formatted_items[:2]) if formatted_items else "[]"
)
# 构建操作按钮
actions = [
{
"text": details_text,
"reply": f"{details_reply_prefix}{order_id}"
},
{
"text": logistics_text,
"reply": f"{logistics_reply_prefix}{order_id}"
}
]
# 构建单个订单
order_data = {
"orderNumber": order_id,
"date": created_at,
"status": status_text,
"items": formatted_items,
"actions": actions
}
order_list_data.append(order_data)
# 构建 content_attributesorder_list 格式)
content_attributes = {
"orders": order_list_data
}
# 记录发送的数据(用于调试)
logger.info(
"Sending order list",
conversation_id=conversation_id,
orders_count=len(orders),
language=language,
payload_preview=str(content_attributes)[:1000]
)
# 发送 order_list 类型消息
client = await self._get_client()
payload = {
"content_type": "order_list",
"content": "",
"content_attributes": content_attributes
}
response = await client.post(
f"/conversations/{conversation_id}/messages",
json=payload
)
response.raise_for_status()
return response.json()
# ============ Conversations ============
async def get_conversation(self, conversation_id: int) -> dict[str, Any]:
@@ -739,9 +1245,16 @@ def create_action_buttons(actions: list[dict[str, Any]]) -> dict[str, Any]:
chatwoot_client: Optional[ChatwootClient] = None
def get_chatwoot_client() -> ChatwootClient:
"""Get or create global Chatwoot client instance"""
def get_chatwoot_client(account_id: Optional[int] = None) -> ChatwootClient:
"""Get or create Chatwoot client instance
Args:
account_id: Chatwoot account ID. If not provided, uses settings default.
"""
global chatwoot_client
if account_id is not None:
# 创建指定 account_id 的客户端实例
return ChatwootClient(account_id=account_id)
if chatwoot_client is None:
chatwoot_client = ChatwootClient()
return chatwoot_client