Compare commits

...
Author SHA1 Message Date
ouyangxq 880a8fb57c 接入真实美国通用价表并直接回复报价 2026-11-05 11:23:52 +08:00
ouyangxq a9404f01e2 优化入仓码查询入口与卡片交互 2026-11-04 19:37:17 +08:00
21 changed files with 1312 additions and 57 deletions
+2
View File
@@ -10,9 +10,11 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout |
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
| 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` |
| 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` |
+1 -1
View File
@@ -22,7 +22,7 @@ COPY logistics_chat/ ./logistics_chat/
COPY templates/ ./templates/
COPY static/ ./static/
COPY deploy/__init__.py deploy/wsgi.py ./deploy/
COPY scripts/import_order_test_data.py ./scripts/
COPY scripts/import_order_test_data.py scripts/import_quote_data.py ./scripts/
USER 10001:10001
EXPOSE 5000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
Vendored
+10
View File
@@ -26,6 +26,9 @@ pipeline {
if (!((params.DEBIAN_MIRROR ?: 'https://deb.debian.org') in ['https://deb.debian.org', 'https://mirrors.tuna.tsinghua.edu.cn'])) {
error('Select a supported Debian mirror')
}
if (params.IMPORT_QUOTE_DATA && !(params.QUOTE_SNAPSHOT_CREDENTIAL_ID ==~ /[A-Za-z0-9_.-]+/)) {
error('Select the explicitly supplied private quote snapshot credential')
}
}
sh '''set -eu
for file in deploy/*.sh; do bash -n "$file"; done
@@ -70,6 +73,13 @@ pipeline {
]) {
sh 'bash deploy/publish-gray.sh'
script {
if (params.IMPORT_QUOTE_DATA) {
withCredentials([
file(credentialsId: params.QUOTE_SNAPSHOT_CREDENTIAL_ID, variable: 'QUOTE_SNAPSHOT_FILE')
]) {
sh 'bash deploy/import-gray-quotes.sh'
}
}
if (params.IMPORT_ORDER_SAMPLES) {
withCredentials([
file(credentialsId: params.ORDER_SNAPSHOT_CREDENTIAL_ID, variable: 'ORDER_SNAPSHOT_FILE'),
+15 -2
View File
@@ -1,6 +1,6 @@
# 柏悦智能物流客服系统
基于 Flask + DeepSeek 的中欧/英国跨境物流智能客服,支持多轮对话、运费试算、运单查询、历史会话管理。
基于 Flask + DeepSeek 的跨境物流智能客服,支持美国通用报价、中欧/英国运费试算、多轮对话、运单查询、历史会话管理。
## 功能
@@ -22,9 +22,20 @@
## 报价数据
美国通用报价通过 `scripts/import_quote_data.py` 将本地 Excel 明细导出为私有快照,再导入运行 SQLite;不读取「快速查询」的外部工作簿,不按文件日期或旧船期停用。原始文件、价格快照及运行数据库均不进入 Git 或镜像。
`quote_books` 保存版本,`quote_rates` 保存线路/交货仓/目的仓或分区/税费方式/重量档位/计费单位,`quote_warehouses` 保留仓码的多种分区,`quote_postcodes` 保存带前导零的邮编及偏远类别,`quote_documents` 保存当前资料中的业务说明。每条价格保留源工作表和单元格。指定 `--activate` 才原子切换美国报价版本,导入不修改订单和客户范围。
```powershell
python scripts/import_quote_data.py --xlsx <本地报价.xlsx> --output <新私有快照.json>
python scripts/import_quote_data.py --snapshot <私有快照.json> --db <运行数据库.db> --activate
```
美国报价先由本地规则计算并通过既有 SSE 返回,不等待模型调用。仅给「美国30KG」可先看到不同海派、交货仓和美西/美中/美东的基础价格;提供仓码、邮编、箱数和尺寸后在当前会话继续核算。同一仓码有多种分区时并列展示;更换目的仓或邮编后不重用旧目的地。体积、附加费和承运限制尚不明确时继续补充条件,不编造缺失费率。现有欧洲/英国报价使用下述原有来源。
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。美国使用已激活的本地价表;未导入美国价表或其他未覆盖目的地时补充条件或由人工确认。英国按方报价不套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|---|---|---|
@@ -266,6 +277,8 @@ data: {"d": 1}
登录后可以直接问“我有多少个运单”“我的订单数量”“已完成的订单有多少”。统计每次按当前账号的授权范围查表,返回订单票数和其中已登记入仓码的票数;不要求先提供入仓码,也不沿用上一票订单作为统计范围。支持订单总量和明确的业务状态;其他筛选条件不冒充总量返回,头程订单完成不等于签收。统计会话与订单卡片一样绑定客户和范围,退出后隐藏,重新登录同一身份及范围后恢复。
点击“查入仓码”会打开查询框,填写号码后查询;游客先登录。回复及历史卡片中的入仓码也可点击,自动带入号码,可复制或查询最新轨迹。查询结果接在当前聊天中,草稿和待发送附件保留,人工接待中需要先返回 AI 问答。
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
umask 077
: "${QUOTE_SNAPSHOT_FILE:?}"
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
: "${GRAY_DEPLOY_DIR:?}" "${SOURCE_COMMIT:?}" "${BUILD_NUMBER:?}"
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ && "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 2
root=$GRAY_DEPLOY_DIR
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
release_id="${SOURCE_COMMIT:0:12}-b$BUILD_NUMBER"
release="$root/releases/$release_id"
quote_sha=$(sha256sum "$QUOTE_SNAPSHOT_FILE" | awk '{print $1}')
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
scp "${ssh_options[@]}" "$QUOTE_SNAPSHOT_FILE" "$target:$release/quote-input.json"
ssh "${ssh_options[@]}" "$target" bash -s -- "$root" "$release_id" "$SOURCE_COMMIT" "$quote_sha" <<'IMPORT'
set -Eeuo pipefail
umask 077
root=$1
release_id=$2
source_commit=$3
quote_sha=$4
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release_id" =~ ^[a-f0-9]{12}-b[0-9]+$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
[[ "$quote_sha" =~ ^[a-f0-9]{64}$ ]] || exit 2
release="$root/releases/$release_id"
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
input="$root/data/imports/quotes-$release_id"
owned_input=0
cleanup() {
rm -f -- "$release/quote-input.json"
if [[ "$owned_input" == 1 ]]; then
rm -f -- "$input/quotes.json"
rmdir -- "$input" 2>/dev/null || true
fi
}
trap cleanup EXIT
[[ "$(cat "$root/current-release")" == "$release" ]] || exit 2
container=baiyue-logistics-chat-gray
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
[[ "$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container")" == "$source_commit" ]] || exit 2
printf '%s %s\n' "$quote_sha" "$release/quote-input.json" | sha256sum --check --status
[[ ! -e "$input" ]] || { echo 'Existing quote import input requires inspection.' >&2; exit 2; }
install -d -m 700 -o 10001 -g 10001 "$root/data/imports" "$input"
owned_input=1
install -m 600 -o 10001 -g 10001 "$release/quote-input.json" "$input/quotes.json"
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups")/("pre-quote-import-"+sys.argv[1]+".db"); p.parent.mkdir(exist_ok=True); assert not p.exists(); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p)); source.backup(target); target.close(); source.close(); print("Chat SQLite backup completed before quote import.")' "$release_id"
docker exec "$container" python scripts/import_quote_data.py \
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/quotes-$release_id/quotes.json" --activate
echo 'General quote version activated; order data and customer grants preserved.'
IMPORT
+10 -2
View File
@@ -27,7 +27,8 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 知识检索 | `logistics_chat/knowledge.py`:`knowledge_list`、`search_knowledge` | [knowledge.md](knowledge.md) 与代码同步,程序不读取该文档 |
| 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史卡片保留兼容渲染 |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`count_orders`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`、`count_orders`,不让模型拼 SQL |
| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
@@ -115,11 +116,12 @@ tests/ # 按业务模块组织的回归
- 登录后的订单数量查询由 `parse_count_query` 识别,`count_orders` 使用与单号查询相同的 `tenant_filter` 交集规则,必须显式提供非空客户范围;SQL 直接计算订单票数和已登记入仓码的订单票数,按需过滤明确的业务状态。无额外筛选时统计授权范围全部订单,不复用上一票单号;无法识别的附加条件不静默丢弃,也不交给模型猜数。成功统计返回的内部 `_customer_data` 标记由 `chat_service` 消费并移除,使纯文本统计同样在保存/发送前绑定会话客户和范围;不增加公开 SSE 字段或管理接口。
- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
- 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。
- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
- 历史卡片以 `__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,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
@@ -129,6 +131,12 @@ tests/ # 按业务模块组织的回归
## 验证与维护
美国报价由 `quote_workbook.py` 导出当前 Excel 本地明细;`quote_data.py` 管理版本与 SQLite;`quote_engine.py` 负责纯匹配/Decimal 计价;`quote_service.py` 续接当前会话并通过 `answer_quote` 返回既有 `t`/`d` 事件。分流位置在订单权限检查之后、旧报价/模型分流之前。
新建库不预置价格;私有快照事务导入、显式激活,读取使用同一事务固定版本,后续查询读取更新后的价格。不访问外部工作簿,不按文件日期或旧船期停用。仓码重复分区并列;邮编存文本;上下文仅来自当前访客拥有的同一会话,新的完整问题、其他国家及订单/理赔话题构成边界。回复不包含原始快照、联系人、文件路径或旧船期,不新增公开导入/管理接口。
`quote_documents` 保存当前资料说明,复杂业务说明及尚未实现的附加费仍需逐项扩展匹配规则,不代表已全部自动结算。生产镜像必须显式包含 `scripts/import_quote_data.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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
+4
View File
@@ -35,6 +35,8 @@
| `ASR_PROVIDER` | `baidu` 启用语音;空值关闭 |
| `BAIDU_ASR_API_KEY_CREDENTIAL_ID` / `BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID` | 本服务百度语音专用 Secret text 引用 |
| `IMPORT_ORDER_SAMPLES` | 默认关闭;明确需要补样本时才启用 |
| `IMPORT_QUOTE_DATA` | 默认关闭;本次明确导入通用美国报价才启用 |
| `QUOTE_SNAPSHOT_CREDENTIAL_ID` | 默认空;本次私有报价快照的临时 Secret file 引用 |
| `ORDER_SNAPSHOT_CREDENTIAL_ID` / `TRACKING_SNAPSHOT_CREDENTIAL_ID` | 本次导入的临时 Secret file 引用 |
运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。
@@ -45,6 +47,8 @@
## 发布步骤
报价导入使用 `IMPORT_QUOTE_DATA` 与临时 Secret file,调用 `deploy/import-gray-quotes.sh`:核对快照 SHA 和运行提交、备份 SQLite,再通过生产镜像中的 `scripts/import_quote_data.py --activate` 事务导入。该开关默认关闭,后续普通发布保留已导入数据;导入不修改订单、轨迹或客户范围。原始 Excel 与快照不进入 Git、Docker 上下文或镜像;导入结束清除临时文件,验收后移除临时 Jenkins 文件凭据。新增参数只追加,不改变既有构建参数及其默认值。
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
+3
View File
@@ -13,6 +13,7 @@ from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
is_shipping_destination_reply)
from .tracking import CARD_PREFIX
from .order_data import answer_query, extract_reference
from .quote_service import answer_quote
@@ -90,6 +91,8 @@ 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:
order_answer = answer_quote(msg, conv_id)
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
+2
View File
@@ -149,6 +149,8 @@ def init_db():
init_order_table(conn)
from .customer_service import init_customer_tables
init_customer_tables(conn)
from .quote_data import init_quote_tables
init_quote_tables(conn)
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall()
for cid, title in old:
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
+144
View File
@@ -0,0 +1,144 @@
"""Versioned, private quote imports and read-only runtime access."""
import json
import re
import sqlite3
from decimal import Decimal, InvalidOperation
def init_quote_tables(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS quote_books (
id INTEGER PRIMARY KEY, source_sha256 TEXT NOT NULL UNIQUE,
source_name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 0)''')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_rates (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, service TEXT NOT NULL,
kind TEXT NOT NULL, origin TEXT NOT NULL, zone TEXT NOT NULL,
unit TEXT NOT NULL, tax TEXT NOT NULL, min_weight TEXT NOT NULL,
price TEXT NOT NULL, eta TEXT NOT NULL, destinations_json TEXT NOT NULL,
source_sheet TEXT NOT NULL, source_cell TEXT NOT NULL)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_quote_rates_book ON quote_rates(book_id)')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_warehouses (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, code TEXT NOT NULL,
zone TEXT NOT NULL, state TEXT NOT NULL, postal TEXT NOT NULL,
address TEXT NOT NULL, source_sheet TEXT NOT NULL, source_cell TEXT NOT NULL)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_quote_warehouse_code ON quote_warehouses(book_id,code)')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_postcodes (
book_id INTEGER NOT NULL, postal TEXT NOT NULL, severity TEXT NOT NULL,
fee TEXT, PRIMARY KEY(book_id,postal))''')
connection.execute('''CREATE TABLE IF NOT EXISTS quote_documents (
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, sheet TEXT NOT NULL,
source_cell TEXT NOT NULL, content TEXT NOT NULL)''')
def decimal_text(value, positive=False):
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
raise ValueError('Invalid quote amount') from None
if not number.is_finite() or number < 0 or number > 100000000 or (positive and not number):
raise ValueError('Invalid quote amount')
return format(number, 'f')
def import_snapshot(connection, snapshot, *, activate=False):
"""Validate before writing; replace an active book atomically, never order data."""
if not isinstance(snapshot, dict) or snapshot.get('schema') != 1:
raise ValueError('Unsupported quote snapshot')
digest = snapshot.get('source_sha256', '')
if not re.fullmatch('[0-9a-f]{64}', digest):
raise ValueError('Invalid quote source checksum')
source_name = str(snapshot.get('source_name', ''))[:240]
if not source_name:
raise ValueError('Missing quote source name')
limits = {'rates':10000, 'warehouses':10000, 'postcodes':50000, 'documents':5000}
for key, limit in limits.items():
if not isinstance(snapshot.get(key), list) or len(snapshot[key]) > limit:
raise ValueError('Invalid quote records')
rates = []
for row in snapshot['rates']:
if (row.get('kind') not in ('truck', 'parcel') or row.get('unit') not in ('kg', 'cbm')
or row.get('tax') not in ('included', 'excluded') or not row.get('service')
or not isinstance(row.get('destinations'), list)
or len(row['destinations']) > 200):
raise ValueError('Invalid quote rate')
rates.append((str(row['service'])[:100], row['kind'], str(row.get('origin', ''))[:40],
str(row.get('zone', ''))[:40], row['unit'], row['tax'],
decimal_text(row.get('min_weight', 0)), decimal_text(row['price'], positive=True),
str(row.get('eta', ''))[:200], json.dumps(row['destinations'], ensure_ascii=False),
str(row['source_sheet'])[:100], str(row['source_cell'])[:20]))
warehouses = [(str(r.get('code', '')).strip().upper()[:40], str(r.get('zone', ''))[:40],
str(r.get('state', ''))[:40], str(r.get('postal', ''))[:20], str(r.get('address', ''))[:1000],
str(r['source_sheet'])[:100], str(r['source_cell'])[:20]) for r in snapshot['warehouses']]
postcodes = []
for row in snapshot['postcodes']:
if not re.fullmatch('[0-9]{5}', row.get('postal', '')) or row.get('severity') not in ('remote','extended','extreme'):
raise ValueError('Invalid quote postcode')
postcodes.append((row['postal'], row['severity'], None if row.get('fee') is None else decimal_text(row['fee'])))
documents = [(str(r['sheet'])[:100], str(r['cell'])[:20], str(r['text'])[:16000]) for r in snapshot['documents']]
if not rates:
raise ValueError('Quote snapshot contains no rates')
init_quote_tables(connection)
if not connection.in_transaction:
connection.execute('BEGIN IMMEDIATE')
connection.execute('SAVEPOINT quote_import')
try:
existing = connection.execute('SELECT id FROM quote_books WHERE source_sha256=?', (digest,)).fetchone()
if existing:
book_id = existing[0]
else:
book_id = connection.execute('INSERT INTO quote_books(source_sha256,source_name) VALUES(?,?)',
(digest, source_name)).lastrowid
connection.executemany('''INSERT INTO quote_rates(book_id,service,kind,origin,zone,unit,tax,
min_weight,price,eta,destinations_json,source_sheet,source_cell) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)''',
[(book_id, *r) for r in rates])
connection.executemany('''INSERT INTO quote_warehouses(book_id,code,zone,state,postal,address,source_sheet,source_cell)
VALUES(?,?,?,?,?,?,?,?)''', [(book_id, *r) for r in warehouses])
connection.executemany('INSERT INTO quote_postcodes(book_id,postal,severity,fee) VALUES(?,?,?,?)',
[(book_id, *r) for r in postcodes])
connection.executemany('INSERT INTO quote_documents(book_id,sheet,source_cell,content) VALUES(?,?,?,?)',
[(book_id, *r) for r in documents])
if activate:
connection.execute('UPDATE quote_books SET active=0 WHERE active<>0')
connection.execute('UPDATE quote_books SET active=1 WHERE id=?', (book_id,))
connection.execute('RELEASE quote_import')
except Exception:
connection.execute('ROLLBACK TO quote_import')
connection.execute('RELEASE quote_import')
raise
return {'book_id':book_id, 'rates':len(rates), 'warehouses':len(warehouses),
'postcodes':len(postcodes), 'documents':len(documents), 'active':bool(activate), 'existing':bool(existing)}
def load_book():
"""One read transaction prevents mixed versions during a concurrent import."""
from .db import connect
connection = connect()
try:
connection.row_factory = sqlite3.Row
connection.execute('BEGIN')
book = connection.execute('SELECT id FROM quote_books WHERE active=1 ORDER BY id DESC LIMIT 1').fetchone()
if not book:
return None
book_id = book['id']
result = {'id':book_id}
for table, name in (('quote_rates','rates'), ('quote_warehouses','warehouses'), ('quote_documents','documents')):
result[name] = [dict(r) for r in connection.execute(f'SELECT * FROM {table} WHERE book_id=?', (book_id,))]
for rate in result['rates']:
rate['destinations'] = json.loads(rate.pop('destinations_json'))
return result
finally:
connection.close()
def postcode_fee(book_id, postal):
from .db import connect
connection = connect()
try:
connection.row_factory = sqlite3.Row
row = connection.execute('SELECT severity,fee FROM quote_postcodes WHERE book_id=? AND postal=?',
(book_id, postal)).fetchone()
return dict(row) if row else None
finally:
connection.close()
+191
View File
@@ -0,0 +1,191 @@
"""Bounded quote matching and Decimal arithmetic, independent of Flask/models."""
from decimal import Decimal, ROUND_HALF_UP
import re
from .pricing import parse_shipping_intent, detect_dim_unit, dims_to_cm, shipping_region
US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|'
r'(?<![A-Za-z])(?:USA|(?-i:US)|United States|America)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往|\bto\s+)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))', re.I)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|\bEXX\b|\b(?:quote|price|cost|freight)\b', re.I)
def parse_intent(message, warehouse_codes):
parsed = parse_shipping_intent(message)
country = 'US' if US.search(message) else ('other' if shipping_region(message) is not None else None)
tokens = re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])', message.upper())
warehouse = next((token for token in tokens if token in warehouse_codes), None)
postal = re.search(r'(?<![0-9])(\d{5})(?:-\d{4})?(?![0-9])', message)
cbm = re.search(r'(\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)', message, re.I)
boxes = re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)', message, re.I)
origin = '华南' if re.search(r'华南|深圳|东莞', message) else ('义乌' if re.search(r'华东|义乌', message) else None)
if re.search(r'华南|深圳|东莞', message) and re.search(r'华东|义乌', message):
origin = None
services = [name for name in ('美森正班','美森加班','EXX','合德','普船') if name.lower() in message.lower()]
if '美森' in message and not any(name.startswith('美森') for name in services):
services += ['美森正班','美森加班']
kinds = []
if re.search(r'海派|快递|UPS|FEDEX', message, re.I):
kinds.append('parcel')
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路', message):
kinds.append('truck')
air_only = bool(re.search(r'空运|空派|\bair\b', message, re.I) and not re.search(r'海运|海派|海卡|海铁|\bsea\b', message, re.I))
zone = next((value for value in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if value in message), None)
each = re.search(r'每(?:箱|件)\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)', message, re.I)
weight = str(parsed['weight']) if parsed['weight'] is not None else None
count = int(boxes.group(1)) if boxes else None
if each and count:
weight = str(Decimal(each.group(1))*count)
return {'country':country, 'weight':weight, 'dims':parsed['dims'], 'dim_unit':detect_dim_unit(message),
'cbm':cbm.group(1) if cbm else None, 'boxes':count, 'origin':origin, 'services':services or None,
'kinds':kinds or None, 'transport':parsed['channel'], 'air_only':True if air_only else None, 'warehouse':warehouse,
'postal':postal.group(1) if postal else None, 'zone':zone,
'tax':'excluded' if re.search(r'不包税|自税|不含税', message) else ('included' if '包税' in message else None)}
def is_followup(message, intent):
if re.search(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
return False
text = message.strip().strip(',,。.!!?? ')
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样)', text):
return True
if intent['warehouse'] and re.fullmatch(r'(?:目的仓(?:是)?|仓码(?:是)?)?\s*[A-Za-z0-9]+(?:仓)?(?:呢)?', text):
return True
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
if len(text) <= 45 and not (intent['country'] and intent['weight']) and (
intent['services'] or intent['kinds'] or intent['transport'] or intent['air_only'] or intent['weight'] or intent['dims'] or intent['cbm']):
return True
return bool(re.fullmatch(r'(?:价格|运费|费用|时效|多少钱)(?:呢|是多少|怎么样)?', text))
def broad_zone(postal):
if not postal:
return None
if postal.startswith(('8','9')):
return '美西'
if postal.startswith('46') or postal[0] in '0123':
return '美东'
return '美中'
def variants(intent, book):
warehouse = intent['warehouse']
found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else []
zoned = [r for r in found if r['zone']]
if zoned:
unique = {}
for row in zoned:
postal = intent['postal'] or row['postal'][:5]
key = (row['zone'], postal)
unique.setdefault(key, {'label':f"{warehouse} · {row['zone']}", 'postal':postal,
'zone':broad_zone(postal), 'warehouse':warehouse})
return list(unique.values())
postal = intent['postal']
if not postal and found:
match = re.search(r'(?<![0-9])([0-9]{5})(?:-[0-9]{4})?(?![0-9])', found[0]['address'])
postal = match.group(1) if match else None
zone = intent['zone']
if zone and zone[0] in '东中西':
zone = {'东':'美东','中':'美中','西':'美西'}[zone[0]]
return [{'label':warehouse or postal or zone or '美国', 'postal':postal, 'zone':zone or broad_zone(postal), 'warehouse':warehouse}]
def quantity(intent, rate):
real = Decimal(intent['weight'] or '0')
volume = Decimal(intent['cbm']) if intent['cbm'] else None
if volume is None and intent['dims']:
cm = [Decimal(str(value)) for value in dims_to_cm(intent['dims'], intent['dim_unit'])]
volume = cm[0]*cm[1]*cm[2]/Decimal(1000000)*(intent['boxes'] or 1)
if rate['unit'] == 'cbm':
if volume is None:
return None
return max(volume, real/Decimal(363))
volume_weight = ((volume or Decimal(0))*1000000/6000 if rate['kind']=='parcel'
else (volume or Decimal(0))*167)
charge = max(real, volume_weight)
if rate['kind'] == 'parcel':
charge = max(charge, Decimal(12)*(intent['boxes'] or 1))
return charge if real or volume else None
def match_rates(intent, book, variant):
selected = []
kinds = intent['kinds'] or (['truck','parcel'] if variant['warehouse'] else ['parcel'])
for rate in book['rates']:
if rate['kind'] not in kinds or (intent['origin'] and intent['origin'] != rate['origin']):
continue
if intent['services'] and not any(name in rate['service'] for name in intent['services']):
continue
if intent['tax'] and rate['tax'] != intent['tax']:
continue
if rate['kind'] == 'truck':
if not variant['warehouse'] or variant['warehouse'] not in [value.upper() for value in rate['destinations']]:
continue
elif variant['zone'] and rate['zone'] != variant['zone']:
continue
selected.append(rate)
groups = {}
for rate in selected:
key = (rate['service'],rate['origin'],rate['zone'],rate['unit'],rate['tax'])
charge = quantity(intent,rate)
if rate['kind'] == 'parcel' and Decimal(rate['min_weight']) > (charge or Decimal(12)):
continue
if key not in groups or Decimal(rate['min_weight']) > Decimal(groups[key]['min_weight']):
groups[key] = rate
return list(groups.values())
def number(value, places=2):
return format(Decimal(value).quantize(Decimal(10)**-places, rounding=ROUND_HALF_UP), 'f').rstrip('0').rstrip('.') if places else str(value)
def render_rates(intent, book, destinations, remote_fees):
lines = []
if intent['weight']:
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
else:
lines.append('美国线路报价:')
for destination, fees in zip(destinations, remote_fees):
rates = match_rates(intent, book, destination)
if not rates:
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
continue
if destination['label'] != '美国':
lines.append(destination['label']+':')
grouped = {}
for rate in rates:
key = (rate['service'], rate['origin'], rate['unit'], rate['tax'])
grouped.setdefault(key, []).append(rate)
for (service, origin, unit, tax), rows in grouped.items():
parts = []
for rate in rows:
amount = quantity(intent,rate)
price = Decimal(rate['price'])
rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else ''
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
if amount is not None:
total = amount*price
if fees and fees['fee'] is not None and rate['kind'] == 'parcel':
total += amount*Decimal(fees['fee'])
rate_text += f",{number(amount,3)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
parts.append(rate_text)
eta = re.sub(r'\s*无时效赔偿', '', rows[0]['eta']).strip()
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
+ (f'({eta})' if eta else ''))
if fees:
if fees['fee'] is None:
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
else:
lines.append(f"海派已计入偏远附加费 {number(fees['fee'])}元/KG。")
if not any(intent.get(key) for key in ('warehouse','postal')):
lines.append('发我目的仓码或邮编,可继续匹配到仓线路。')
if not intent['weight'] and not intent['cbm'] and not intent['dims']:
lines.append('货物多少公斤、几箱?')
elif not intent['boxes'] or not intent['dims']:
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
return '\n'.join(lines)
+94
View File
@@ -0,0 +1,94 @@
"""Resolve same-conversation quote edits and return local facts without model I/O."""
from decimal import Decimal, InvalidOperation
import re
import sqlite3
from .db import connect
from .quote_data import load_book, postcode_fee
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
def intent_with_context(message, conv_id, book):
codes = {row['code'] for row in book['warehouses']}
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
intent = parse_intent(message,codes)
replaced_destination = ({'postal','zone'} if intent['warehouse'] else
({'warehouse','zone'} if intent['postal'] else
({'warehouse','postal'} if intent['zone'] else set())))
if intent['transport'] or intent['kinds'] or intent['services']:
replaced_destination.add('air_only')
destination_selected = any(intent.get(key) for key in ('warehouse','postal','zone'))
if intent['air_only'] or intent['tax'] == 'excluded':
replaced_destination.add('kinds')
if conv_id and is_followup(message,intent):
connection = connect()
try:
recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 8", (conv_id,)).fetchall()
finally:
connection.close()
for (previous,) in recent:
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', previous):
break
earlier = parse_intent(previous,codes)
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
break
if earlier['country'] == 'other':
break
if not destination_selected:
if earlier['warehouse']:
replaced_destination.update(('postal','zone'))
destination_selected = True
elif earlier['postal']:
replaced_destination.update(('warehouse','zone'))
destination_selected = True
elif earlier['zone']:
replaced_destination.update(('warehouse','postal'))
destination_selected = True
for key, value in earlier.items():
if intent.get(key) is None and key not in replaced_destination:
intent[key] = value
if not is_followup(previous, earlier):
break
if intent['warehouse'] and intent['country'] is None:
intent['country'] = 'US'
return intent
def answer_quote(message, conv_id):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
return None
# Avoid a new read for ordinary chat; short followups need their local context.
if not (US.search(message) or QUOTE_WORDS.search(message) or conv_id or
re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
return None
try:
book = load_book()
if not book:
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
return None
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
any(intent.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
return None
for key in ('weight','cbm'):
if intent[key] is not None and not (Decimal(0) < Decimal(intent[key]) <= Decimal(1000000)):
return {'t':'请提供大于 0 的有效重量或体积。'}
if intent['boxes'] is not None and not 0 < intent['boxes'] <= 10000:
return {'t':'请提供有效的货物箱数。'}
if intent['dims'] and any(not 0 < value <= 100000 for value in intent['dims']):
return {'t':'请提供大于 0 的有效长宽高。'}
if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出。'}
destinations = variants(intent,book)
fees = []
for destination in destinations:
postal = destination['postal']
if postal and (postal[:3] in ('006','007','008','009','966','967','968','969','995','996','997','998','999') or postal=='89124'):
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
fees.append(postcode_fee(book['id'],postal) if postal else None)
return {'t':render_rates(intent,book,destinations,fees)}
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
return {'t':'报价数据暂时无法读取,请稍后重试。'}
+119
View File
@@ -0,0 +1,119 @@
"""Extract only this workbook's local detail cells; never evaluate external links."""
import hashlib
from pathlib import Path
import posixpath
import re
from xml.etree import ElementTree as ET
from zipfile import ZipFile
from .quote_data import decimal_text
NS = {'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
TRUCK_SHEETS = ('美森正班海卡','美森加班海卡','EXX海卡','合德海卡','纽约EXX海铁',
'洛杉矶普船海卡','洛杉矶普船全卡','芝加哥普船海铁','休斯顿普船海卡','纽约普船海卡','萨凡纳普船海卡')
def read_sheets(path):
path = Path(path)
if path.stat().st_size > 20*1024*1024:
raise ValueError('Quote workbook is too large')
with ZipFile(path) as archive:
if sum(info.file_size for info in archive.infolist()) > 100*1024*1024:
raise ValueError('Expanded quote workbook is too large')
strings = []
if 'xl/sharedStrings.xml' in archive.namelist():
for entry in ET.fromstring(archive.read('xl/sharedStrings.xml')):
strings.append(''.join(t.text or '' for t in entry.findall('.//s:t', NS)))
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
rels = {r.attrib['Id']:r.attrib['Target'] for r in ET.fromstring(archive.read('xl/_rels/workbook.xml.rels'))}
result = {}
for sheet in workbook.find('s:sheets', NS):
target = rels[sheet.attrib['{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id']]
target = target.lstrip('/') if target.startswith('/') else posixpath.normpath('xl/'+target)
rows = {}
for cell in ET.fromstring(archive.read(target)).findall('s:sheetData/s:row/s:c', NS):
ref = cell.attrib['r']
column = re.sub('[0-9]', '', ref)
if len(column) != 1 or column > 'Q':
continue
raw = cell.find('s:v', NS)
value = raw.text if raw is not None else ''
if cell.attrib.get('t') == 's':
value = strings[int(value)]
elif cell.attrib.get('t') == 'inlineStr':
value = ''.join(t.text or '' for t in cell.findall('.//s:t', NS))
formula = cell.find('s:f', NS)
if formula is not None and formula.text and re.search(r'\[[0-9]+\]', formula.text):
continue
if value:
rows.setdefault(int(re.sub('[^0-9]', '', ref)), {})[column] = value
result[sheet.attrib['name']] = rows
return result
def export_snapshot(path):
sheets = read_sheets(path)
missing = set(TRUCK_SHEETS + ('海派','FBA分区表','亚马逊仓库代码表','UPS&FedEx偏远地区表')) - sheets.keys()
if missing:
raise ValueError('Quote workbook is missing required local detail sheets')
result = {'schema':1, 'source_name':Path(path).name,
'source_sha256':hashlib.sha256(Path(path).read_bytes()).hexdigest(),
'rates':[], 'warehouses':[], 'postcodes':[], 'documents':[]}
for name in TRUCK_SHEETS:
rows = sheets[name]
destination_col = 'A' if name in TRUCK_SHEETS[:4] else 'B'
price_cols = ('B','C','D') if destination_col == 'A' else ('C','D','E')
eta_col = 'E' if destination_col == 'A' else 'F'
for number, row in rows.items():
if number < 6 or not re.fullmatch(r'\d+(?:\.\d+)?', row.get(price_cols[0], '')):
continue
destinations = [p.strip() for p in re.split(r'[/\n、]+', row.get(destination_col, '')) if p.strip()]
if not destinations:
raise ValueError('Price row is missing destinations')
for column, origin, unit, tax in zip(price_cols, ('义乌','义乌','华南'), ('cbm','kg','kg'), ('excluded','included','included')):
result['rates'].append({'service':name, 'kind':'truck', 'origin':origin, 'zone':'',
'unit':unit, 'tax':tax, 'min_weight':'0', 'price':decimal_text(row[column], positive=True),
'eta':row.get(eta_col, ''), 'destinations':destinations, 'source_sheet':name, 'source_cell':f'{column}{number}'})
parcel = sheets['海派']
for origin, header, first in (('义乌',4,9), ('华南',13,18)):
for start_col in ('C','F','I','L'):
for offset, tier in enumerate(('12','51','101')):
column = chr(ord(start_col)+offset)
for index, zone in enumerate(('美西','美中','美东')):
number = first+index
result['rates'].append({'service':parcel[header][start_col], 'kind':'parcel', 'origin':origin,
'zone':zone, 'unit':'kg', 'tax':'included', 'min_weight':tier,
'price':decimal_text(parcel[number][column], positive=True),
'eta':parcel[header+2][start_col], 'destinations':[], 'source_sheet':'海派', 'source_cell':f'{column}{number}'})
for name, first in (('FBA分区表',3), ('亚马逊仓库代码表',2)):
for number, row in sheets[name].items():
if number < first or not row.get('A'):
continue
is_zone = name == 'FBA分区表'
result['warehouses'].append({'code':row['A'].strip().upper(),
'zone':row.get('B','') if is_zone else '', 'state':row.get('C','') if is_zone else '',
'postal':row.get('D','') if is_zone else '', 'address':row.get('B','') if not is_zone else '',
'source_sheet':name, 'source_cell':f'A{number}'})
for number, row in sheets['UPS&FedEx偏远地区表'].items():
if number <= 23:
continue
for column, severity, fee in (('B','remote','1'), ('F','extended','2'), ('J','extreme',None)):
postal = row.get(column, '')
if postal:
result['postcodes'].append({'postal':postal, 'severity':severity, 'fee':fee})
for name, rows in sheets.items():
if name in ('快速查询','UPS&FedEx偏远地区表','FBA分区表','亚马逊仓库代码表'):
continue
for number, row in rows.items():
if number <= 1 or (name in TRUCK_SHEETS and number <= 5):
continue
# Long detail text contains rules; short lookup rows contain service fees.
for column, value in row.items():
if len(value) >= 20 and not re.search(r'公司地址|TEL:|船名航次|预计开船日期|关税查询网址', value):
result['documents'].append({'sheet':name, 'cell':f'{column}{number}', 'text':value})
if name in ('一件代发收费','FDA申报清单'):
text = ';'.join(row.values())
result['documents'].append({'sheet':name,'cell':f'A{number}','text':text})
return result
+40
View File
@@ -0,0 +1,40 @@
"""Export local workbook details or import a private quote snapshot into SQLite."""
import argparse
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from logistics_chat.quote_data import import_snapshot
from logistics_chat.quote_workbook import export_snapshot
def main():
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--xlsx', type=Path)
source.add_argument('--snapshot', type=Path)
parser.add_argument('--output', type=Path, help='New private snapshot output for --xlsx')
parser.add_argument('--db', type=Path)
parser.add_argument('--activate', action='store_true', help='Use this version as general US rates without expiry')
args = parser.parse_args()
if args.xlsx:
if not args.output or args.db or args.output.exists():
parser.error('--xlsx requires a new --output path, without --db')
snapshot = export_snapshot(args.xlsx)
with args.output.open('x', encoding='utf-8') as target:
json.dump(snapshot, target, ensure_ascii=False, separators=(',', ':'))
print(json.dumps({key:len(snapshot[key]) for key in ('rates','warehouses','postcodes','documents')}))
return
if not args.db or not args.db.is_file() or not args.snapshot.is_file() or args.snapshot.stat().st_size > 20*1024*1024:
parser.error('Import requires an existing database and a private snapshot smaller than 20 MB')
snapshot = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
with closing(sqlite3.connect(args.db)) as connection, connection:
print(json.dumps(import_snapshot(connection, snapshot, activate=args.activate)))
if __name__ == '__main__':
main()
+7 -2
View File
@@ -50,10 +50,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; }
.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; }
.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
#customer-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
#customer-error, #order-lookup-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); }
#customer-submit { border-color: var(--brand); background: var(--brand); color: white; }
#customer-submit, #order-lookup-submit { border-color: var(--brand); background: var(--brand); color: white; }
.order-reference-field { display: flex; align-items: center; gap: 8px; }
.order-reference-field input { min-width: 0; flex: 1; }
#order-reference-copy { flex-shrink: 0; min-width: 60px; min-height: 44px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--brand-text); }
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
@@ -150,6 +153,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; }
.track-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 14px; }
.track-no { font-weight: 650; font-size: 17px; letter-spacing: .02em; }
.order-reference { display: block; max-width: 100%; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--brand-text); font: inherit; text-align: left; overflow-wrap: anywhere; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.order-reference:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 3px; }
.track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; }
.track-status { padding: 4px 10px; color: var(--success); background: var(--success-soft); border-radius: 6px; font-size: 12px; white-space: nowrap; }
.track-node { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); }
+73 -37
View File
@@ -41,6 +41,8 @@ let historyRequestVersion = 0;
let followLatest = true;
let voiceInput = null;
let customerLogin = null;
let orderLookup = null;
let lookupRequestVersion = 0;
let customerRecovery = null;
let creatingConv = null;
let sendPreparing = false;
@@ -234,6 +236,11 @@ const I18N = {
customerUsernamePlaceholder: "请输入账号", customerPasswordPlaceholder: "请输入密码",
customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。",
customerLogoutFailed: "退出失败,请重试。",
lookupTitle: "查入仓码", lookupOrderTitle: "查询订单", lookupPlaceholder: "请输入入仓码",
lookupOrderPlaceholder: "请输入订单号", lookupSubmit: "查询轨迹", lookupSending: "查询中…",
lookupInvalid: "请输入完整的入仓码或订单号。", lookupFailed: "查询未发送成功,请重试。",
lookupBusy: "请等待当前操作完成后再查询。", lookupAIOnly: "请先返回 AI 问答,再查询订单轨迹。",
lookupAction: "查询或复制",
newChat: "新建对话",
clearAll: "清空全部历史",
history: "历史对话",
@@ -345,6 +352,11 @@ const I18N = {
customerUsernamePlaceholder: "Enter username", customerPasswordPlaceholder: "Enter password",
customerSigningIn: "Signing in…", customerLoginFailed: "Could not sign in. Please try again.",
customerLogoutFailed: "Could not sign out. Please try again.",
lookupTitle: "Track by inbound code", lookupOrderTitle: "Look up an order", lookupPlaceholder: "Enter an inbound code",
lookupOrderPlaceholder: "Enter an order number", lookupSubmit: "Track shipment", lookupSending: "Sending…",
lookupInvalid: "Enter a complete inbound code or order number.", lookupFailed: "Could not send the lookup. Please retry.",
lookupBusy: "Wait for the current action to finish before looking up an order.", lookupAIOnly: "Return to AI chat before looking up an order.",
lookupAction: "Look up or copy",
newChat: "New Chat",
clearAll: "Clear All History",
history: "History",
@@ -462,6 +474,10 @@ function applyLanguage(lang){
LANG = lang === "en" ? "en" : "zh";
const dict = I18N[LANG];
if(customerLogin) customerLogin.render();
if(orderLookup) orderLookup.render();
document.querySelectorAll('.order-reference').forEach(function(button){
button.setAttribute('aria-label',t('lookupAction')+' '+t(button.dataset.referenceLabel)+' '+button.textContent);
});
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
document.querySelectorAll("[data-i18n]").forEach(function(element){
const value = dict[element.dataset.i18n];
@@ -617,7 +633,7 @@ function welcomeHTML(){
const h = t("hotList");
const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : `data-question="${escHtml(question)}"`;
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(question)}"`;
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
}).join("");
return `
@@ -859,6 +875,7 @@ function ensureConv(){
function resetConversation(id){
flushDraft();
emojiPicker.close();
if(orderLookup) orderLookup.cancel();
if(voiceInput) voiceInput.cancel();
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
@@ -941,7 +958,16 @@ function fillTrackCard(el, card){
no.className = "track-no";
const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value);
const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo';
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(inbound ? inbound.value : card.no);
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>";
if(isOrder){
const reference=document.createElement('button');
const value=String(inbound ? inbound.value : card.no), version=conversationVersion;
reference.type='button';reference.className='order-reference';reference.textContent=value;
reference.dataset.referenceLabel=numberLabel;
reference.setAttribute('aria-label',t('lookupAction')+' '+t(numberLabel)+' '+value);
reference.onclick=function(){if(version === conversationVersion) return showOrderLookup(value,inbound ? 'inbound' : 'order',reference);};
no.appendChild(reference);
}else no.innerHTML += escHtml(card.no);
const st = document.createElement("div");
st.className = "track-status";
st.textContent = card.status;
@@ -1121,12 +1147,33 @@ window.askNewTopic = function(words){
return doSend();
}
window.sendOrder = function(){
const no = prompt("请输入运单号:");
if(no && no.trim()){
inputDom.value = "我的运单号是 " + no.trim() + ",帮我查一下轨迹";
doSend();
}
window.sendOrder = function(){ return showOrderLookup(); };
function lookupBlocked(){
if(window.humanSid || humanRequestToken || humanClosing || humanSendingToken) return t('lookupAIOnly');
if(openingConv !== null) return t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading');
if(uploadPending || sendPreparing || window.__busy) return t('lookupBusy');
return '';
}
function sameCustomerContext(version,user){
return version === conversationVersion || (customerRecovery &&
customerRecovery.from === version && customerRecovery.to === conversationVersion &&
(!user || user.id === customerRecovery.customerId));
}
async function showOrderLookup(reference,kind,source){
const blocked=lookupBlocked();
if(blocked){setComposerStatus(blocked,true);return;}
const version=conversationVersion;
const requestVersion=++lookupRequestVersion;
function isCurrent(user){return requestVersion === lookupRequestVersion && sameCustomerContext(version,user);}
return customerLogin.require(function(user){
if(!isCurrent(user)) return;
const blocked=lookupBlocked();
if(blocked){setComposerStatus(blocked,true);return;}
orderLookup.open(reference,kind,source);
},isCurrent);
}
function updateHumanMode(){
@@ -1438,9 +1485,7 @@ function attachCustomerLogin(bubble, question){
const version = conversationVersion;
const convId = currentConvId;
function sameContext(user){
return version === conversationVersion || (customerRecovery &&
customerRecovery.from === version && customerRecovery.to === conversationVersion &&
(!user || user.id === customerRecovery.customerId));
return sameCustomerContext(version,user);
}
button.onclick = function(){
if(version !== conversationVersion) return;
@@ -1498,27 +1543,7 @@ function attachActions(bubble, rawText, question, showRetry){
copyBtn.textContent = t(copied ? 'copied' : 'copyFailed');
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000);
};
if(navigator.clipboard && navigator.clipboard.writeText){
return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback);
}else{
return fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.readOnly = true;
ta.style.position = "fixed";
ta.style.opacity = "0";
const previousFocus = document.activeElement;
document.body.appendChild(ta);
let copied = false;
try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){}
finally{
ta.remove();
if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true});
}
done(copied);
}
return SupportUI.copyText(plain).then(done);
};
bar.appendChild(copyBtn);
@@ -1604,7 +1629,8 @@ function renderFollowups(bubble){
bubble.appendChild(wrap);
}
async function doSend(retryText){
async function doSend(retryText, showQuestion){
if(showQuestion && lookupBlocked()) return false;
if(voiceInput && voiceInput.busy()){ setComposerStatus(t('voiceWait')); return false; }
const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim();
@@ -1640,9 +1666,9 @@ async function doSend(retryText){
}finally{
if(version === conversationVersion) sendPreparing = false;
}
if(version !== conversationVersion || humanRequestToken) return;
if(version !== conversationVersion || humanRequestToken || (showQuestion && window.humanSid)) return;
if(!isRetry || showQuestion) addMsg(fullContent, true);
if(!isRetry){
addMsg(fullContent, true);
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
if(attachmentVersion === uploadVersion) clearAttachment();
focusComposer();
@@ -1939,6 +1965,7 @@ function syncChatViewport(){
chatContainer.style.top = '';
}
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
if(orderLookup) orderLookup.viewport(chatContainer.style.height, chatContainer.style.top);
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
autoGrow();
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
@@ -2016,7 +2043,7 @@ voiceInput = ChatVoice.create({
}
});
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); },
onOpen:function(){ if(voiceInput) voiceInput.cancel(); if(isNarrow()){sideOpen=false;renderSidebar();} syncChatViewport(); },
onChange:function(user,previous){
if(previous === undefined) return;
if(previous && (!user || user.id !== previous.id)){
@@ -2034,16 +2061,25 @@ customerLogin = CustomerLogin.create({fetch:fetch, text:t,
});
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
customerLogin.refresh();
orderLookup = OrderLookup.create({text:t,canQuery:lookupBlocked,
onOpen:function(){emojiPicker.close();if(voiceInput) voiceInput.cancel();if(isNarrow()){sideOpen=false;renderSidebar();}syncChatViewport();},
query:function(reference,kind){return doSend((kind === 'order' ? '查询订单 ' : '查询入仓码 ')+reference,true);}
});
orderLookup.viewport(chatContainer.style.height,chatContainer.style.top);
window.addEventListener('resize', function(){
if(!isNarrow()) voiceInput.setMode(false);
});
document.querySelectorAll('[data-question-index]').forEach(function(button){
button.addEventListener('click', function(){
window.askNewTopic(t('quick')[Number(button.dataset.questionIndex)]);
const index=Number(button.dataset.questionIndex);
if(index === 0) return showOrderLookup(undefined,undefined,button);
return window.askNewTopic(t('quick')[index]);
});
});
chatDom.addEventListener('click', function(event){
const lookup=event.target.closest('button[data-order-lookup]');
if(lookup){showOrderLookup(undefined,undefined,lookup);return;}
const question = event.target.closest('button[data-question]');
if(question) window.askNewTopic(question.dataset.question);
const human = event.target.closest('button[data-human-support]');
+91
View File
@@ -0,0 +1,91 @@
// One lookup form for shortcuts and saved order cards; no draft or chat ownership changes.
var OrderLookup = {
create(options){
const overlay=document.getElementById('order-lookup');
const form=document.getElementById('order-lookup-form');
const input=document.getElementById('order-reference');
const label=document.getElementById('order-reference-label');
const title=document.getElementById('order-lookup-title');
const submit=document.getElementById('order-lookup-submit');
const cancel=document.getElementById('order-lookup-cancel');
const copy=document.getElementById('order-reference-copy');
const error=document.getElementById('order-lookup-error');
let kind='inbound', opener=null, busy=false, copying=false, copyState='', version=0, timer=null;
overlay.hidden=true;
function valid(value){
return /^[A-Za-z0-9][A-Za-z0-9_-]{5,63}$/.test(value) && (value.match(/[0-9]/g) || []).length >= 3;
}
function status(message){ error.textContent=message || ''; error.hidden=!message; }
function render(){
title.textContent=options.text(kind === 'inbound' ? 'lookupTitle' : 'lookupOrderTitle');
label.textContent=options.text(kind === 'inbound' ? 'order_inbound' : 'orderNo');
input.placeholder=options.text(kind === 'inbound' ? 'lookupPlaceholder' : 'lookupOrderPlaceholder');
submit.textContent=options.text(busy ? 'lookupSending' : 'lookupSubmit');
cancel.textContent=options.text('customerCancel');
copy.textContent=options.text(copyState || 'copy');
submit.disabled=busy; cancel.disabled=busy; input.disabled=busy;
copy.disabled=busy || copying || !valid(input.value.trim());
}
function clearTimer(){ if(timer !== null) clearTimeout(timer); timer=null; }
function close(){
version+=1;clearTimer();overlay.hidden=true;
busy=false;copying=false;copyState='';input.value='';status('');render();
if(opener && opener.isConnected) opener.focus({preventScroll:true});
opener=null;
}
function open(reference, referenceKind, source){
version+=1;clearTimer();kind=referenceKind === 'order' ? 'order' : 'inbound';
busy=false;copying=false;copyState='';
opener=source || document.activeElement;input.value=reference || '';status('');render();overlay.hidden=false;
if(options.onOpen) options.onOpen();
input.focus({preventScroll:true});
}
input.addEventListener('input',function(){ clearTimer();copyState='';status('');render(); });
copy.onclick=async function(){
const reference=input.value.trim(), token=version;
if(busy || copying || !valid(reference)) return;
copying=true;copyState='';status('');render();
const copied=await SupportUI.copyText(reference);
if(token !== version) return;
copying=false;
if(input.value.trim() === reference){
copyState=copied ? 'copied' : 'copyFailed';
if(!copied) status(options.text('copyFailed'));
timer=setTimeout(function(){copyState='';timer=null;render();},copied ? 1200 : 3000);
}
render();
};
form.addEventListener('submit',async function(event){
event.preventDefault();
if(busy) return;
const reference=input.value.trim(), token=version;
if(!valid(reference)){status(options.text('lookupInvalid'));input.focus({preventScroll:true});return;}
const blocked=options.canQuery();
if(blocked){status(blocked);return;}
busy=true;status('');render();
try{
const accepted=await options.query(reference,kind);
if(token !== version) return;
if(accepted) close();else status(options.text('lookupFailed'));
}catch(error){if(token === version) status(options.text('lookupFailed'));}
finally{if(token === version){busy=false;render();}}
});
cancel.onclick=function(){if(!busy) close();};
overlay.addEventListener('click',function(event){if(event.target === overlay && !busy) close();});
overlay.addEventListener('keydown',function(event){
if(event.key === 'Escape' && !busy){event.preventDefault();close();return;}
if(event.key === 'Tab'){
const controls=[input,copy,cancel,submit].filter(control=>!control.disabled);
event.preventDefault();
if(controls.length){
const index=controls.indexOf(document.activeElement);
controls[(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length].focus({preventScroll:true});
}
}
});
render();
return {open:open,cancel:close,render:render,viewport:function(height,top){
overlay.style.height=height || '';overlay.style.top=top || '';overlay.style.bottom=height ? 'auto' : '';
}};
}
};
+21
View File
@@ -1,5 +1,26 @@
// Shared controls for the customer page and service workspace.
var SupportUI = {
async copyText(text){
try{
if(navigator.clipboard && navigator.clipboard.writeText){
await navigator.clipboard.writeText(text);
return true;
}
}catch(error){}
const field = document.createElement('textarea');
field.value=text; field.readOnly=true;
field.style.position='fixed'; field.style.opacity='0';
const previous=document.activeElement;
document.body.appendChild(field);
try{
field.focus({preventScroll:true}); field.select();
return Boolean(document.execCommand('copy'));
}catch(error){ return false; }
finally{
field.remove();
if(previous && previous.isConnected) previous.focus({preventScroll:true});
}
},
initAttachments(container, label){
container.addEventListener('error', function(event){
const image = event.target;
+15
View File
@@ -82,9 +82,24 @@
</form>
</section>
</div>
<div id="order-lookup" class="customer-login-overlay" hidden>
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="order-lookup-title">
<h2 id="order-lookup-title">查入仓码</h2>
<form id="order-lookup-form">
<label id="order-reference-label" for="order-reference">入仓码</label>
<div class="order-reference-field">
<input id="order-reference" name="reference" type="text" placeholder="请输入入仓码" maxlength="64" autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false" required>
<button id="order-reference-copy" type="button">复制</button>
</div>
<p id="order-lookup-error" role="alert" hidden></p>
<div class="customer-login-actions"><button id="order-lookup-cancel" type="button">取消</button><button id="order-lookup-submit" type="submit">查询轨迹</button></div>
</form>
</section>
</div>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
+194 -13
View File
@@ -58,6 +58,7 @@ async function customerPage(pageOptions = {}){
closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this;
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
if(selector === 'button[data-order-lookup]' && this.attributes['data-order-lookup'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
@@ -106,6 +107,7 @@ async function customerPage(pageOptions = {}){
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/customer/session' ? {ok:true,authenticated:Boolean(pageOptions.customer),user:pageOptions.customer || null}
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
@@ -113,7 +115,7 @@ async function customerPage(pageOptions = {}){
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'customer.js', 'drafts.js', 'voice.js', 'chat.js']){
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
@@ -270,7 +272,7 @@ test('switching topics aborts an upload and does not erase the new conversation
});
app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange();
await app.click(app.quickButtons[0]);
await app.click(app.quickButtons[1]);
await flush();
assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false);
@@ -308,7 +310,7 @@ test('clearing history blocks new conversations until its timed request settles'
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]);
await app.click(app.quickButtons[1]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
@@ -379,13 +381,16 @@ test('recalling an earlier reply preserves newer replies in the customer log', a
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
test('tracking does not interrupt human support while quote and exception shortcuts start AI topics', async () => {
const app = await customerPage();
for(const button of app.quickButtons) await app.click(button);
await app.click(app.quickButtons[0]);
assert.equal(app.context.window.humanSid,7);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
for(const button of app.quickButtons.slice(1)) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['按入仓码查轨迹', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(requests.length, 2);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 2);
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
@@ -440,8 +445,8 @@ test('a repeated quick click while a new conversation is being created sends onl
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click();
app.quickButtons[0].click();
app.quickButtons[1].click();
app.quickButtons[1].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'}));
await flush();
@@ -452,7 +457,7 @@ test('a late conversation creation cannot replace the newest quick question', as
const app = await customerPage({human:false});
let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click();
app.quickButtons[1].click();
await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'}));
await flush();
@@ -619,12 +624,188 @@ test('inbound codes are the primary card identity for live replies and restored
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB20261029001-01');await flush();
let heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/);
assert.match(app.element('chatDom').querySelector('.order-data-fields').textContent,/订单号.*FL202610290001/);
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
assert.match(heading.textContent,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.textContent,/FL202610290001/);
});
async function submitOrderLookup(app,reference){
app.element('order-reference').value=reference;
app.element('order-reference').listeners.input();
await app.element('order-lookup-form').listeners.submit({preventDefault(){}});await flush();
}
test('the lookup shortcut keeps the current conversation, draft and attachment until a real reference is submitted', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
await app.context.window.fillInput('原来的聊天');await flush();
app.element('input-text').value='尚未发送的草稿';app.element('input-text').listeners.input();
app.context.window.__pendingImg='/uploads/next-image.png';
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
await app.click(app.quickButtons[0]);
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('.left-panel').classList.contains('left-panel-hide'),false);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
assert.equal(app.element('input-text').value,'尚未发送的草稿');
await submitOrderLookup(app,'hello');
assert.match(app.element('order-lookup-error').textContent,/完整/);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
await submitOrderLookup(app,' INB202611040001 ');
const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1);
assert.equal(last.body.msg,'查询入仓码 INB202611040001');
assert.equal(last.body.conv_id,1);
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1);
assert.equal(app.element('order-lookup').hidden,true);
assert.match(app.element('chatDom').textContent,/查询入仓码 INB202611040001/);
assert.equal(app.element('input-text').value,'尚未发送的草稿');
assert.equal(app.context.window.__pendingImg,'/uploads/next-image.png');
});
test('the guest shortcut signs in before opening lookup and sends no empty order question', async () => {
const app=await customerPage({human:false});
app.hooks['/api/customer/login']=()=>response({ok:true,user:{id:1,name:'admin'}});
app.element('input-text').value='游客原草稿';app.element('input-text').listeners.input();
await app.click(app.quickButtons[0]);
assert.equal(app.element('customer-login').hidden,false);
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat' || call.url==='/stream_chat').length,0);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('input-text').value,'游客原草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
await app.click(app.element('order-lookup-cancel'));
assert.equal(app.element('input-text').value,'游客原草稿');
});
test('live and saved inbound references open a prefilled form and copy only the code', async () => {
const copied=[];
const app=await customerPage({human:false,customer:{id:1,name:'admin'},history:[{id:9,title:'历史订单',ctime:'now'}],
clipboard:{writeText:async value=>{copied.push(value);}}});
const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[{key:'inbound',value:'INB202611040001'}],nodes:[],summary:'订单概要'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB202611040001');await flush();
await app.click(app.element('chatDom').querySelector('.order-reference'));
assert.equal(app.element('order-reference').value,'INB202611040001');
await app.click(app.element('order-reference-copy'));
assert.deepEqual(copied,['INB202611040001']);
assert.equal(app.element('order-reference-copy').textContent,'已复制');
await app.click(app.element('order-lookup-cancel'));
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
await app.click(app.element('chatDom').querySelector('.order-reference'));
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card:{...card,nodes:[{time:'2026-11-04 12:00',text:'最新物流节点',place:''}]}})+'\n\ndata: {"d":1}\n\n']);
await submitOrderLookup(app,'INB202611040001');
const last=app.calls.filter(call=>call.url==='/stream_chat').at(-1);
assert.equal(last.body.conv_id,9);assert.equal(last.body.msg,'查询入仓码 INB202611040001');
assert.equal(app.element('chatDom').querySelectorAll('.track-card').length,2);
assert.match(app.element('chatDom').querySelectorAll('.track-card').at(-1).textContent,/最新物流节点/);
});
test('an order without an inbound code keeps its truthful order-number label and lookup kind', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
const card={kind:'order',no:'FL202611040001',status:'履约中',fields:[],nodes:[],summary:'订单概要'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput(card.no);await flush();
const header=app.element('chatDom').querySelector('.track-no');
assert.match(header.textContent,/订单号/);assert.doesNotMatch(header.textContent,/入仓码/);
await app.click(header.querySelector('.order-reference'));
assert.equal(app.element('order-reference-label').textContent,'订单号');
await submitOrderLookup(app,card.no);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').at(-1).body.msg,'查询订单 '+card.no);
});
test('lookup copy failure is visible and a retry succeeds without changing the typed code', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},copyResult:false,
clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
await app.click(app.quickButtons[0]);
app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input();
await app.click(app.element('order-reference-copy'));
assert.equal(app.element('order-reference-copy').textContent,'复制失败');
assert.match(app.element('order-lookup-error').textContent,/复制失败/);
app.context.document.execCommand=()=>true;
await app.click(app.element('order-reference-copy'));
assert.equal(app.element('order-reference-copy').textContent,'已复制');
assert.equal(app.element('order-lookup-error').hidden,true);
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.context.document.body.children.length,0);
});
test('a duplicated lookup submit sends once and a failed creation keeps the reference editable', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});let release;
await app.click(app.quickButtons[0]);
app.hooks['/api/new_chat']=()=>new Promise(resolve=>{release=resolve;});
const pending=submitOrderLookup(app,'INB202611040001');await flush();
await app.element('order-lookup-form').listeners.submit({preventDefault(){}});
assert.equal(app.calls.filter(call=>call.url==='/api/new_chat').length,1);
release(response({ok:false},503));await pending;
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.element('order-reference').disabled,false);
app.hooks['/api/new_chat']=()=>response({id:11,welcome:'welcome'});
await submitOrderLookup(app,'INB202611040001');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
});
test('lookup does not stop an active AI reply or clear a pending draft', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
app.hooks['/stream_chat']=()=>({ok:true,body:{getReader:()=>({read:()=>new Promise(()=>{})})}});
await app.context.window.fillInput('正在回复的问题');await flush();
app.element('input-text').value='下一条草稿';app.element('input-text').listeners.input();
await app.click(app.quickButtons[0]);
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.context.window.__busy,true);
assert.match(app.element('composer-status').textContent,/等待当前操作/);
assert.equal(app.element('input-text').value,'下一条草稿');
await app.click(app.element('send-button'));
});
test('a late shortcut sign-in cannot open a private lookup after the user changes topics', async () => {
const app=await customerPage({human:false});let release;
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
await app.click(app.quickButtons[0]);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.click(app.element('.new-chat-btn'));
app.element('input-text').value='新话题草稿';app.element('input-text').listeners.input();
release(response({ok:true,user:{id:1,name:'admin'}}));await signing;
assert.equal(app.element('order-lookup').hidden,true);
assert.equal(app.element('input-text').value,'新话题草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('welcome lookup uses the same form and follows the mobile keyboard viewport', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390,viewportHeight:844});
const welcome=app.element('lookup-welcome');welcome.setAttribute('data-order-lookup','');
app.element('chatDom').listeners.click({target:welcome});await flush();
assert.equal(app.element('order-lookup').hidden,false);
await app.resizeViewport(430,1,80);
assert.equal(app.element('order-lookup').style.height,'430px');
assert.equal(app.element('order-lookup').style.top,'80px');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('a late repeated shortcut status response cannot erase the latest lookup input', async () => {
const user={id:1,name:'admin'}, app=await customerPage({human:false,customer:user}), releases=[];
app.hooks['/api/customer/session']=()=>new Promise(resolve=>{releases.push(resolve);});
const first=app.quickButtons[0].click(), second=app.quickButtons[0].click();await flush();
releases[1](response({ok:true,authenticated:true,user}));await second;await flush();
app.element('order-reference').value='INB202611040001';app.element('order-reference').listeners.input();
releases[0](response({ok:true,authenticated:true,user}));await first;await flush();
assert.equal(app.element('order-reference').value,'INB202611040001');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('closing lookup restores the shortcut instead of reopening the composer keyboard', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'},width:390});
const input=app.element('input-text');input.focus();
await app.click(app.quickButtons[0]);
await app.click(app.element('order-lookup-cancel'));
assert.equal(app.context.document.activeElement,app.quickButtons[0]);
assert.equal(input.focusCalls.length,1);
assert.equal(app.quickButtons[0].focusCalls.at(-1).preventScroll,true);
});
test('order details can expand without hiding the shipment timeline', async () => {
+221
View File
@@ -0,0 +1,221 @@
"""Synthetic prices exercise the quote contract; no customer workbook is committed."""
from copy import deepcopy
from decimal import Decimal
from pathlib import Path
import json
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from zipfile import ZipFile
from logistics_chat import create_app, db
from logistics_chat.quote_data import import_snapshot, load_book
from logistics_chat.quote_engine import quantity
def fixture_snapshot():
rates = []
for origin, extra in (('义乌',0), ('华南',1)):
for zone, price in (('美西',2), ('美中',3), ('美东',4)):
for minimum, discount in ((12,0),(51,1),(101,1.5)):
rates.append({'service':'普船海派','kind':'parcel','origin':origin,'zone':zone,
'unit':'kg','tax':'included','min_weight':str(minimum), 'price':str(price+extra-discount),
'eta':'船开后若干天','destinations':[],'source_sheet':'合成海派','source_cell':'C9'})
for unit, tax, price in (('kg','included','3'),('cbm','excluded','100')):
rates.append({'service':'合成海卡','kind':'truck','origin':origin,'zone':'','unit':unit,
'tax':tax,'min_weight':'0','price':price,'eta':'船开后若干天','destinations':['ZZZ1'],
'source_sheet':'合成海卡','source_cell':'C8'})
return {'schema':1,'source_name':'synthetic-quotes.xlsx','source_sha256':'1'*64,'rates':rates,
'warehouses':[{'code':'ZZZ1','zone':zone,'state':'AA','postal':'01234','address':'',
'source_sheet':'合成分区','source_cell':f'A{index+3}'} for index,zone in enumerate(('东1区','东2区'))],
'postcodes':[{'postal':'01234','severity':'remote','fee':'1'},
{'postal':'99901','severity':'extreme','fee':None},
{'postal':'12345','severity':'extreme','fee':None}],
'documents':[{'sheet':'合成说明','cell':'A1','text':'合成业务说明,不含真实数据'}]}
def frames(response):
return [json.loads(f[6:]) for f in response.get_data(as_text=True).split('\n\n') if f.startswith('data: ')]
class QuoteTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory(); self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.app = create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'1'*64})
with self.app.app_context():
connection = db.connect()
import_snapshot(connection,fixture_snapshot(),activate=True)
connection.commit(); connection.close()
def ask(self, question, cid=None):
events = frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':cid}))
self.assertEqual(events[-1],{'d':1},events)
self.assertFalse(any(e.get('e') for e in events),events)
return events[0]['t']
def conversation(self):
return self.client.post('/api/new_chat').json['id']
def test_guest_us_30kg_always_gets_prices_without_model_or_login(self):
with patch('logistics_chat.ai_client.post_completion') as model:
for question in ('发送美国30KG','30kg发US仓海运报价','30kg发USFBA仓海运报价','30kg to US'):
with self.subTest(question=question):
text = self.ask(question)
self.assertIn('美西 2元/KG,30KG = 60元',text)
self.assertIn('美东 4元/KG,30KG = 120元',text)
self.assertIn('华南',text)
model.assert_not_called()
def test_ambiguous_warehouse_returns_both_zones_and_own_rates(self):
text = self.ask('美国30kg发ZZZ1海派,义乌交仓')
for zone in ('东1区','东2区'):
self.assertIn('ZZZ1 · '+zone,text)
self.assertEqual(text.count('30KG = 150元'),2)
self.assertIn('海派已计入偏远附加费 1元/KG',text)
self.assertNotIn('华南',text)
def test_context_resumes_after_warehouse_origin_weight_and_dimensions(self):
cid = self.conversation()
self.ask('美国30kg报价',cid)
text = self.ask('ZZZ1',cid)
self.assertIn('美国 30KG',text)
self.assertIn('合成海卡',text)
text = self.ask('华南呢',cid)
self.assertIn('30KG',text)
self.assertIn('华南',text)
self.assertNotIn('· 义乌',text)
text = self.ask('50kg呢',cid)
self.assertIn('美国 50KG',text)
text = self.ask('2箱每箱15kg,40x30x20cm呢',cid)
self.assertIn('美国 30KG',text)
fresh = self.ask('ZZZ1')
self.assertNotIn('美国 30KG',fresh)
self.assertIn('多少公斤',fresh)
def test_new_topics_do_not_reuse_old_quote_or_cross_order_privacy(self):
from logistics_chat.quote_service import answer_quote
cid = self.conversation()
self.ask('美国30kg报价',cid)
with self.app.test_request_context('/'):
self.assertIsNone(answer_quote('新的一票50kg发德国报价',cid))
db.save_message(cid,'user','美国货物丢失如何理赔?')
self.assertIsNone(answer_quote('50kg呢',cid))
events = frames(self.client.post('/stream_chat',json={'msg':'查询入仓码 INB20260001','conv_id':cid}))
self.assertTrue(events[0]['login_required'])
def test_replaced_destination_stays_replaced_after_other_followups(self):
cid = self.conversation()
self.ask('美国30kg发ZZZ1海派,义乌',cid)
text = self.ask('换成邮编12345',cid)
self.assertNotIn('ZZZ1',text)
self.ask('那空运呢',cid)
text = self.ask('海运呢',cid)
self.assertNotIn('ZZZ1',text)
self.assertIn('12345',text)
self.assertIn('美国 30KG',text)
def test_new_quote_tables_start_empty_and_existing_orders_are_untouched(self):
with self.app.app_context():
connection = db.connect()
before = connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0]
import_snapshot(connection,fixture_snapshot(),activate=True)
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0],before)
self.assertEqual(connection.execute("SELECT postal FROM quote_postcodes WHERE severity='remote'").fetchone()[0],'01234')
connection.execute('UPDATE quote_books SET active=0'); connection.commit(); connection.close()
text = self.ask('美国30kg海运报价')
self.assertIn('尚未包含',text)
def test_tier_boundaries_and_per_box_minimum_use_decimal_calculation(self):
for weight, amount in ((50,100),(51,51),(100,100),(101,50.5)):
with self.subTest(weight=weight):
self.assertIn(f'{amount:g}元',self.ask(f'美国美西{weight}kg海派,义乌交仓'))
text = self.ask('美国美西2箱每箱5kg,义乌海派')
self.assertIn('美国 10KG',text)
self.assertIn('24KG = 48元',text)
with self.app.app_context():
book = load_book()
rate = next(r for r in book['rates'] if r['kind']=='parcel')
intent = {'weight':'10','cbm':None,'dims':[60,50,40],'dim_unit':'cm','boxes':2}
self.assertEqual(quantity(intent,rate),Decimal(40))
def test_non_tax_volume_uses_density_rule_and_missing_volume_still_replies(self):
text = self.ask('美国30kg发ZZZ1,不包税,义乌')
self.assertIn('100元/CBM',text)
self.assertNotIn('30CBM',text)
text = self.ask('美国363kg发ZZZ1,不包税,0.5方,义乌')
self.assertIn('1CBM = 100元',text)
def test_extreme_remote_air_missing_and_invalid_inputs_finish_cleanly(self):
text = self.ask('美国30kg邮编12345海派,义乌')
self.assertIn('遥远地址',text)
self.assertIn('30KG = 120元',text)
self.assertIn('不接收',self.ask('美国30kg邮编99901海派'))
self.assertIn('空运费率当前资料没有列出',self.ask('美国30kg空运报价'))
self.assertIn('大于 0',self.ask('美国0kg海运报价'))
def test_quote_history_is_saved_and_isolated_by_visitor(self):
cid = self.conversation()
text = self.ask('美国30KG报价',cid)
history = self.client.get('/api/get_messages',query_string={'conv_id':cid}).json
self.assertTrue(any(m['content']==text for m in history))
other = self.app.test_client(); other.post('/api/visitor/session',json={'key':'2'*64})
self.assertEqual(other.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
def test_import_activation_is_fresh_idempotent_and_invalid_import_preserves_current(self):
with self.app.app_context():
connection = db.connect()
result = import_snapshot(connection,fixture_snapshot(),activate=True)
self.assertTrue(result['existing'])
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_rates').fetchone()[0],22)
newer = deepcopy(fixture_snapshot()); newer['source_sha256']='2'*64
for rate in newer['rates']:
rate['price']='9'
import_snapshot(connection,newer,activate=True); connection.commit()
invalid = deepcopy(newer); invalid['source_sha256']='3'*64; invalid['rates'][0]['price']='NaN'
with self.assertRaises(ValueError):
import_snapshot(connection,invalid,activate=True)
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_books WHERE active=1').fetchone()[0],1)
self.assertEqual(connection.execute('SELECT source_sha256 FROM quote_books WHERE active=1').fetchone()[0],'2'*64)
connection.close()
self.assertIn('美西 9元/KG,30KG = 270元',self.ask('美国30kg海派,义乌'))
def test_failed_insert_rolls_back_entire_version_and_import_does_not_commit_caller(self):
with self.app.app_context():
connection = db.connect()
broken = deepcopy(fixture_snapshot()); broken['source_sha256']='2'*64
broken['postcodes'].append(deepcopy(broken['postcodes'][0]))
with self.assertRaises(sqlite3.IntegrityError):
import_snapshot(connection,broken,activate=True)
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_books').fetchone()[0],1)
connection.rollback()
newer = deepcopy(fixture_snapshot()); newer['source_sha256']='3'*64
import_snapshot(connection,newer,activate=True)
connection.rollback()
self.assertEqual(connection.execute('SELECT source_sha256 FROM quote_books WHERE active=1').fetchone()[0],'1'*64)
connection.close()
def test_lowercase_english_pronoun_is_not_a_us_destination(self):
from logistics_chat.quote_service import answer_quote
with self.app.app_context():
self.assertIsNone(answer_quote('Can you help us quote 30kg to Germany?',None))
def test_local_xlsx_reader_ignores_external_formulas_and_fake_full_width_headers(self):
from logistics_chat.quote_workbook import read_sheets
with tempfile.TemporaryDirectory() as folder:
path = Path(folder)/'fixture.xlsx'
ns = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
with ZipFile(path,'w') as archive:
archive.writestr('xl/workbook.xml',f'<workbook xmlns="{ns}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="合成资料" sheetId="1" r:id="rId1"/></sheets></workbook>')
archive.writestr('xl/_rels/workbook.xml.rels','<Relationships><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>')
archive.writestr('xl/worksheets/sheet1.xml',f'<worksheet xmlns="{ns}"><sheetData><row r="2"><c r="A2" t="inlineStr"><is><t>01234</t></is></c><c r="B2"><f>[1]Sheet!A1</f><v>999</v></c><c r="XFD2" t="inlineStr"><is><t>列16384</t></is></c></row></sheetData></worksheet>')
self.assertEqual(read_sheets(path),{'合成资料':{2:{'A':'01234'}}})
if __name__ == '__main__':
unittest.main()