From 880a8fb57cd16c6294ee38d0389375f5a3d394f0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 5 Nov 2026 11:23:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E=E7=BE=8E?= =?UTF-8?q?=E5=9B=BD=E9=80=9A=E7=94=A8=E4=BB=B7=E8=A1=A8=E5=B9=B6=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=9B=9E=E5=A4=8D=E6=8A=A5=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + Dockerfile | 2 +- Jenkinsfile | 10 ++ README.md | 15 ++- deploy/import-gray-quotes.sh | 55 ++++++++ docs/architecture.md | 8 +- docs/deployment.md | 4 + logistics_chat/chat_service.py | 3 + logistics_chat/db.py | 2 + logistics_chat/quote_data.py | 144 ++++++++++++++++++++ logistics_chat/quote_engine.py | 191 ++++++++++++++++++++++++++ logistics_chat/quote_service.py | 94 +++++++++++++ logistics_chat/quote_workbook.py | 119 +++++++++++++++++ scripts/import_quote_data.py | 40 ++++++ tests/test_quotes.py | 221 +++++++++++++++++++++++++++++++ 15 files changed, 905 insertions(+), 4 deletions(-) create mode 100644 deploy/import-gray-quotes.sh create mode 100644 logistics_chat/quote_data.py create mode 100644 logistics_chat/quote_engine.py create mode 100644 logistics_chat/quote_service.py create mode 100644 logistics_chat/quote_workbook.py create mode 100644 scripts/import_quote_data.py create mode 100644 tests/test_quotes.py diff --git a/AGENTS.md b/AGENTS.md index 0067ff3..e90cd3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | 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` | diff --git a/Dockerfile b/Dockerfile index 6322b64..7313fad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/Jenkinsfile b/Jenkinsfile index 01080ee..772dfab 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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'), diff --git a/README.md b/README.md index 9598550..ee6ff2a 100644 --- a/README.md +++ b/README.md @@ -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) | 参考时效 | |---|---|---| diff --git a/deploy/import-gray-quotes.sh b/deploy/import-gray-quotes.sh new file mode 100644 index 0000000..26043bd --- /dev/null +++ b/deploy/import-gray-quotes.sh @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index e6a29df..54d7f3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ tests/ # 按业务模块组织的回归 - 历史卡片以 `__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 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。 @@ -131,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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。 diff --git a/docs/deployment.md b/docs/deployment.md index fb25b00..0cd26b7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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-` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。 2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。 3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。 diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index b807bf4..40be67d 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -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 diff --git a/logistics_chat/db.py b/logistics_chat/db.py index 77f15a7..58a6364 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -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())) diff --git a/logistics_chat/quote_data.py b/logistics_chat/quote_data.py new file mode 100644 index 0000000..eefa376 --- /dev/null +++ b/logistics_chat/quote_data.py @@ -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() diff --git a/logistics_chat/quote_engine.py b/logistics_chat/quote_engine.py new file mode 100644 index 0000000..fe5ebe3 --- /dev/null +++ b/logistics_chat/quote_engine.py @@ -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'(? (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) diff --git a/logistics_chat/quote_service.py b/logistics_chat/quote_service.py new file mode 100644 index 0000000..adda426 --- /dev/null +++ b/logistics_chat/quote_service.py @@ -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'(? 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 diff --git a/scripts/import_quote_data.py b/scripts/import_quote_data.py new file mode 100644 index 0000000..b639eec --- /dev/null +++ b/scripts/import_quote_data.py @@ -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() diff --git a/tests/test_quotes.py b/tests/test_quotes.py new file mode 100644 index 0000000..63ee6cb --- /dev/null +++ b/tests/test_quotes.py @@ -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'') + archive.writestr('xl/_rels/workbook.xml.rels','') + archive.writestr('xl/worksheets/sheet1.xml',f'01234[1]Sheet!A1999列16384') + self.assertEqual(read_sheets(path),{'合成资料':{2:{'A':'01234'}}}) + + +if __name__ == '__main__': + unittest.main()