接入真实美国通用价表并直接回复报价
This commit is contained in:
@@ -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` |
|
||||
|
||||
+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) | 参考时效 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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
|
||||
@@ -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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
||||
|
||||
@@ -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()
|
||||
@@ -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