Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
880a8fb57c | ||
|
|
a9404f01e2 |
@@ -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
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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。历史会话的自动清理仍已关闭。
|
||||
|
||||
@@ -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
@@ -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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
||||
|
||||
@@ -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` 运维镜像。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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':'报价数据暂时无法读取,请稍后重试。'}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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]');
|
||||
|
||||
@@ -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' : '';
|
||||
}};
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user