# DHL / UPS / FedEx 全球附加费查询系统 V1.1

> 输入 **承运商 + 国家 + 邮政编码（优先）/ 城市（无邮编时必填）** → 返回可能产生的附加费、收费、
> **匹配方式**（邮编匹配 / 城市匹配 / 国家规则）与**派送状态**（可否派送上门 / 是否有服务 / 是否只能自提），**并说明为什么**。
> 国家支持 **中文 / 英文 / ISO 代码** 三种写法（`美国` = `United States` = `US`）。

本系统**不做基础运费计算**，只做「地址 → 区域判断 → 附加费规则匹配 → 附加费结果」。
正式数据**只来自官方站点**（dhl.com / ups.com / fedex.com）；没有官方数据一律返回
`UNKNOWN`，**绝不猜测、绝不把 UNKNOWN 当成 NO**。

> **页面最下方（页脚上方）还有一行「燃油附加费速览」**：DHL / UPS / FedEx 在中国大陆
> 与中国香港的燃油附加费（六个来源，两市场严格分开）。它是**参考信息，刻意做小**，
> 主查询功能在上方主体位置。官方数据拿不到时显示原因，**不显示任何猜测值**；
> 官方**当期**费率未公布时，按最近一期显示并**明确标注有效期 +「非当期」**。

## 在线访问（已部署）

| 用途 | 地址 |
|---|---|
| 查询页面（人用） | **https://3dm.store** （www.3dm.store 同证书，可访问） |
| AI 入口（llms.txt 标准） | https://3dm.store/llms.txt |
| AI 完整索引 | https://3dm.store/AI_INDEX.md |
| **纯文本答案页（发给豆包等 AI）** | https://3dm.store/ask?country=US&postal=99546 |

> `/ask` 返回**纯文本**中文结论 + 口径说明，无 JS、无登录、无弹窗 ——
> 把 URL 发给任何「能读链接」的 AI（豆包 / 微信 / 任意助手），它读完即可回答。
> 参数：`country`（必填，中文/英文/ISO）、`postal`、`city`、`carrier`（dhl\|ups\|fedex）。
> 详见 [`llms.txt`](llms.txt) 与 [`AI_INDEX.md`](AI_INDEX.md)。

**部署形态**：AlmaLinux 9.7 · systemd 服务 `surcharge` · 代码目录 `/opt/surcharge` ·
应用监听 8000，经 nginx 反代到 80 端口（`/etc/nginx/conf.d/express.conf`），
对外使用**无端口 URL**（AI 读链接兼容性更好）。时区已设为 `Asia/Shanghai`。

## 三条硬规则

| 规则 | 说明 |
|---|---|
| **只能查当天** | 查询日期恒为服务器当天，不接受历史 / 未来日期；有效期已过的数据被标记为过期，不再作为当前判定依据 |
| **没有邮编也能查** | 国家必填，城市建议填写，**邮编可选**。全球很多国家没有标准邮编，也有的官方清单只按 City/Town 列示 —— 绝不因缺少邮编而拒绝查询或返回 UNKNOWN |
| **每天 10:00 自动检查官方更新** | 定时扫描 20 个区域型官方来源 **+ 6 个燃油官方来源**（DHL/UPS/FedEx × 中国大陆/中国香港），SHA256 / 内容指纹比对发现变化即重新入库，并把更新推送到服务器（服务器未部署时先排队，绝不丢数据） |

---

## 1. 快速开始

```bash
# 1) 安装依赖
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

# 2) 初始化数据库（含种子数据）
python run.py init

# 3) 下载官方原始文件 → 解析入库 → 提取规则 → 采集燃油 → 生成覆盖率
python run.py refresh

# 4) 启动 Web 服务（默认 http://127.0.0.1:8000）
python run.py serve
```

> `refresh` = `fetch → ingest → extract → fuel → fuel-markets → coverage`，已包含燃油附加费采集。
> 只单独补采燃油（例如官方刚更新燃油页面）：
>
> ```bash
> python run.py fuel            # 用已下载的 raw/ 文件重新解析燃油
> python run.py fuel --fetch    # 先重新下载燃油官方文件，再解析
> python run.py fuel-markets    # 【六来源】DHL/UPS/FedEx × 中国大陆/中国香港 燃油附加费
> python run.py fuel-markets --dry-run          # 只采集解析，不写库
> python run.py fuel-markets --carrier DHL      # 只采一家
> python run.py fuel-markets --import UPS:HK=/path/to/official.html   # 人工导入
> ```

浏览器打开 `http://127.0.0.1:8000`，选择快递公司、填国家（中文/英文/代码均可）、
城市（建议）、邮编（可选），点「查询附加费」。

> **普通员工不需要安装任何东西**：管理员部署好后，员工只需打开网页即可。

### 命令行查询 CLI（供 AI / Skill / 脚本调用）

页面背后的**同一套查询引擎**也有一个只读命令行入口，不需要启动 HTTP 服务：

```bash
python -m app.cli <命令> [参数]          # 等价写法：python run.py query <命令> [参数]
```

| 命令 | 用途 |
|---|---|
| `check` | 地址型附加费查询（国家必填 / 邮编优先 / 无邮编必填城市，判断是否偏远） |
| `fuel` | 燃油附加费（按市场 `CN_MAINLAND` / `HK`，与邮编无关） |
| `fuel-matrix` | 三家承运商 × 中国大陆 / 中国香港 的当期燃油矩阵 |
| `reverse` | 附加费反查（适用范围 / 收费方式 / 金额 / 官方来源） |
| `countries` / `surcharges` / `coverage` / `sources` / `meta` | 国家、目录、覆盖率、官方来源、系统信息 |

```bash
# 只给结论（AI 首选）：remote_overall = REMOTE / PARTIAL / NOT_REMOTE / UNKNOWN
python -m app.cli check --country US --postal 99546 --format brief
python -m app.cli check --country DE --city Berlin --carrier dhl --format text
python -m app.cli fuel --carrier ups --market HK --format text
python -m app.cli reverse --carrier fedex --surcharge ODA --country US
```

* 输出默认 JSON；`--format brief` 只给结论，`--format text` 是中文人读摘要。
* `PARTIAL` = 部分承运商偏远（逐家点名），这是最有业务价值的结论。
* **只读**：不采集、不写库、不改数据；查询日期恒为系统当天。
* AI 调用说明见 `.workbuddy/skills/express-surcharge-query/SKILL.md`。

地址合理性验收（3 国 × 3 邮编，覆盖非偏远 / 部分偏远 / 偏远）：

```bash
python scripts/verify_address_matrix.py      # 人类可读矩阵，全通过退出码 0
python scripts/verify_address_matrix.py --json
```

### 每日更新（三选一）

```bash
python run.py schedule                    # 常驻进程，每天 10:00 自动执行
python run.py daily                       # 立刻手动执行一次
python run.py daily && python run.py scan # 只检测变更 / 只扫码
```

> Web 服务启动时会自动挂载后台定时器（可用 `SCHEDULER_ENABLED=false` 关闭，
> 改用 cron / systemd 调用 `python run.py daily`）。

---

## 2. Docker 一键部署

```bash
docker compose up -d
# 打开 http://<服务器IP>:8000
```

---

## 3. 目录结构

```
├── app/
│   ├── api/main.py            # FastAPI：/api/check、/api/check-all、/api/meta、/api/updates …
│   ├── rules/
│   │   ├── engine.py          # 查询引擎（与采集逻辑解耦，数据驱动，8 级匹配优先级）
│   │   └── delivery.py        # 派送状态推导（可否派送上门 / 是否有服务 / 是否只能自提）
│   ├── adapters/
│   │   ├── base.py            # 官方来源下载（curl + 静态浏览器头，不轮换 UA）、SHA256、魔数校验、HTML 挑战页识别
│   │   ├── base_adapter.py    # SourceAdapter 统一接口（发现/下载/解析/标准化/校验/入库）
│   │   ├── dhl/adapter.py     # DHL Express
│   │   ├── ups/adapter.py     # UPS
│   │   ├── fedex/adapter.py   # FedEx Express
│   │   └── fuel/adapter.py    # 燃油附加费专项（区域型管道内）
│   ├── fuel/                  # 【独立模块】六来源燃油附加费（中国大陆 / 中国香港 严格分开）
│   │   ├── markets.py         # 市场/燃油类型/服务/方向/采集状态 枚举与中文口径
│   │   ├── schema.py          # fuel_surcharge / fuel_sources / fuel_collection_runs 建表（幂等补列）
│   │   ├── periods.py         # 官方期间解析（October 5-11, 2026 / 2026 年 10 月 5 日 – …）
│   │   ├── htmlutil.py        # 轻量 HTML 表格定位（不引入额外依赖）
│   │   ├── raw.py             # 原始文件归档 + 语义内容指纹 content_fingerprint()
│   │   ├── validate.py        # 9 项数据校验（不过 → 不入正式库）
│   │   ├── store.py           # 幂等 upsert（历史不覆盖）+ 来源状态 + 批次记录
│   │   ├── provider.py        # FuelSurchargeProvider：getFuelSurcharge() / 首页矩阵 / 历史
│   │   ├── collect.py         # 采集编排（命令行与每日更新共用）
│   │   └── sources/           # 六来源适配器 dhl.py / ups.py / fedex.py + 分层获取基类
│   ├── services/
│   │   ├── normalize.py       # 日期/邮编/城市/单位/金额标准化
│   │   ├── country_names.py   # 国家解析：中文/英文/ISO 代码 → alpha-2
│   │   ├── terms_zh.py        # 全部机器码 → 中文文案（状态/数据状态/匹配方式/单位/附加费名）
│   │   ├── lifecycle.py       # 只能查当天 + 过期清扫
│   │   ├── update_scanner.py  # 官方来源变更扫描（SHA256 / 解析内容指纹比对）
│   │   ├── update_pipeline.py # 每日流水线：清扫 → 扫描 → 入库 → 覆盖率 → 推送
│   │   ├── sync_push.py       # 服务器同步（Outbox 模式，可重试、不丢数据）
│   │   ├── scheduler.py       # 每天 10:00 定时器
│   │   ├── settings.py        # 全部运行期配置（环境变量）
│   │   └── store.py           # 区域数据入库（row_hash 去重）
│   ├── models/db.py           # SQLite 连接、初始化与平滑迁移（ALTER 补列，不重建）
│   └── web/                   # 前端页面（templates + static，界面全中文）
├── raw/{dhl,ups,fedex}/       # 官方原始文件（PDF/XLSX/HTML），永久保留
│   └── <carrier>/{cn_mainland,hk}/  # 六来源燃油原始文件 + .sha256 + metadata.json（累加）
├── data/{normalized,imports}/ # 标准化/导入中间产物
├── scripts/                   # 采集 / 报告 / 每日更新 / 同步推送脚本
│   ├── download_manifest.json # 官方来源清单（20 条，只允许官方域名）
│   ├── ingest_fuel.py         # 燃油附加费采集入口（区域型管道内，可 --fetch）
│   └── fetch_fuel_markets.py  # 【六来源】燃油附加费采集（--carrier/--market/--dry-run/--import）
├── database/                  # schema.sql + seed + surcharge.db
├── tests/                     # 16 个测试文件 / 230 项测试
├── docs/                      # 部署 / 使用 / 更新 / 最终报告 / 燃油六来源验收报告
└── coverage-report.json       # 覆盖率报告
```

> **界面约定**
> - 机器码（`YES` / `CONFIRMED` / `POSTAL_RANGE` / `REMOTE_AREA`…）只在程序内部与 API 字段中使用；
>   所有对外展示的文案由 `app/services/terms_zh.py` 统一映射为中文，页面不出现英文术语。
> - 页面上的「祥海国际快递 · 一级代理折扣 / 联系人微信」品牌标识**只在顶部出现一次**。

---

## 4. 核心设计

| 原则 | 落地方式 |
|---|---|
| 数据真实 > 数据数量 | 全部数据来自官方文件，逐条可追溯；`scripts/extract_rules.py` 对每个数值回查原文，校验不通过即拒绝写入 |
| 官方来源 > 第三方来源 | `official_sources` 只接受 dhl.com / ups.com / fedex.com（含官方子域） |
| 官方 HTML 也能采 | `fetch_url(accept_html=True)` 接受官方 HTML 页面（同时识别并拒绝 Akamai 拦截页 / 挑战页）；燃油等只有网页形式的官方数据同样可入库，不再因「以 `<` 开头」被误判为失败 |
| UNKNOWN > 猜测 | 无官方数据 → `UNKNOWN / DATA_NOT_AVAILABLE`；信息不足 → `NEED_MORE_INFO`；过期 → `EXPIRED`；冲突 → `CONFLICT` |
| 版本不覆盖历史 | `data_versions` + `import_batches` + `row_hash` 去重，重复导入不产生重复行 |
| 查询与采集解耦 | 引擎只读数据库，新增国家只需重新导入数据，**不改核心程序** |
| 数据驱动 | `surcharge_checks` 表决定每家公司在地址查询中报告的附加费项，代码零硬编码 |
| 维度不足不误判 | 只有当「用户提供的地址维度（邮编/城市/省州）」与「官方数据的维度」有交集时，查不到才判 `NO`；否则一律 `UNKNOWN` |
| 邮编优先，城市兜底 | 官方清单是 Postal 型 → 用邮编；City 型 → 用城市；两者都有 → 先精确邮编、再城市。两个维度都缺 → 直接拒绝查询 |
| 过期即过期 | `effective_to` 已过的数据由 `lifecycle.expire_stale_data()` 标记 `EXPIRED`，查询只按当天判定 |

### 四种状态 / 五种数据状态

- **状态**：`YES`（适用）/ `NO`（不适用）/ `UNKNOWN`（待确认）/ `NOT_APPLICABLE`
- **数据状态**：`CONFIRMED`（官方已确认）/ `EXPIRED`（已过期）/ `CONFLICT`（来源冲突）/
  `DATA_NOT_AVAILABLE`（暂无官方数据）/ `NEED_MORE_INFO`（需要补充信息）
- **数据类型**：`TYPE A` 规则型 · `TYPE B` 地址/区域型 · `TYPE C` 时间变化型 · `TYPE D` 条件型

### 地址匹配优先级（8 级）

```
1. 国家 + 精确邮编 + 精确城市
2. 国家 + 精确邮编
3. 国家 + 邮编范围
4. 国家 + 邮编前缀
5. 国家 + 精确城市
6. 国家 + 城市 / 地区（region / town / suburb / 模糊城市名）
7. 国家 + 国家级规则
8. 全球规则（国家组）
   → 仍无命中时才 UNKNOWN
```

> 通用优先级**不覆盖**承运商官方数据自身定义的粒度：官方写 `Postal Code or City`
> 时两种都支持；官方只有 City 时，给邮编只会返回「需要补充信息」，不会被误判为「不适用」。

### 地址数据模型（TYPE B）

所有区域型附加费数据至少支持以下字段：

```
country / region / province / state / city / town / suburb /
postal_code / postal_prefix / postal_range(low+high) /
surcharge_type / tier / scope / charge / currency / unit /
effective_from / effective_to / status / source_url(→official_sources)
```

### 派送状态

由官方区域清单的 `scope`（DELIVERY / PICKUP / BOTH）推导，**不硬编码任何承运商规则**：

| 官方数据情况 | 是否有服务 | 可否派送上门 | 是否只能自提 |
|---|---|---|---|
| 无官方区域数据 | 待确认 | 待确认 | 待确认 |
| 未列入任何偏远/超范围/取件清单 | 有服务 | 可派送上门 | 无需自提 |
| 命中派送类（DELIVERY / BOTH） | 有服务 | 可派送上门 | 无需自提 |
| 只命中取件类（PICKUP） | 有服务 | 不可派送上门 | 只能自提 |

---

## 5. 当前数据覆盖（截至 2026-10-02）

### 区域型（TYPE B）附加费

| 承运商 | 覆盖国家 | 区域记录 | 邮编记录 | 主要附加费类型 |
|---|---|---|---|---|
| DHL Express | 154 | 58,463 | 50,311 | Remote Area |
| UPS | 57 | 41,549 | 102,305 | Extended / Remote / Delivery / Pickup Area |
| FedEx Express | 106 | 58,408 | 137,239 | ODA / OPA / DAS / PAS |

合计 **158,420** 条区域记录 + **289,855** 条邮编记录，来自 **20 个官方来源**。
其中 9,547 条邮编记录带城市、602 条带州（FedEx / UPS 阿拉斯加、夏威夷）。

### 时间变化型（TYPE C）与规则型（TYPE A）附加费

| 承运商 | 附加费 | 数据形态 | 记录数 | 官方来源 |
|---|---|---|---|---|
| DHL Express | 燃油附加费 | 周度费率（含生效期间） | 5 期 | DHL 中国官网燃油页面（HTML） |
| FedEx Express | 燃油附加费 | 月度费率（含生效期间） | 6 期 | FedEx 中国官网燃油页面（HTML） |
| UPS | 燃油附加费 | 官方燃油指数对照表 | 13 档 + 26 档 | UPS 官方 PDF（国际空运 / 美国国内陆运） |
| UPS | Demand / Surge | 时间费率 | 21 + 8 期 | UPS 官方 PDF |
| FedEx | 8 类规则型附加费 | 固定金额规则 | 16 条 | FedEx 官方费率文件 |

> **UPS 当期百分比从何而来？** UPS 官方只公布「燃油指数 → 附加费百分比」对照表，不公布当周指数。
> 因此系统按**对照表**入库，并如实标注「按官方燃油指数对照表」；**绝不臆造当周百分比**。
> 指数由 UPS 官网每周公布，在已知指数时可直接查表得出百分比。

### 六来源燃油附加费（中国大陆 / 中国香港 严格分开）

`app/fuel/` 是一个**独立模块**，专门采集 **DHL / UPS / FedEx × 中国大陆 / 中国香港
共 6 个官方来源**的燃油附加费，并显示在**首页**。完整验收报告见
[`docs/FUEL_ACCEPTANCE_REPORT.md`](docs/FUEL_ACCEPTANCE_REPORT.md)。

**存储维度（绝不用 国家 + 邮编 + 燃油）**

```
Carrier + Market + Service + Direction + Fuel Type + Effective From → Rate
UNIQUE (carrier, market, fuel_type, service, direction, effective_from)
```

- `market` 只允许 `CN_MAINLAND` / `HK` —— 传 `China` 会被直接拒绝（HTTP 400），**绝不合并**；
- `fuel_type` 区分 `EXPRESS` / `AIR_FREIGHT`（UPS 香港两者并存，费率不同，**不合并**）；
- 维度里**没有邮编/城市** —— 燃油是时间型费率，与地址无关；
- `UNIQUE` 键含 `effective_from` → **历史期永不覆盖**，重复采集只更新当期。

**统一查询接口**

```python
getFuelSurcharge(carrier, market, service, direction, effective_date, fallback=True)
```

用严格窗口 `effective_from <= date <= effective_to` 判定**当期费率**；
**当期没有记录时，按用户要求沿用「最近一期」官方费率**
（`is_current=false`、`data_status=LATEST_AVAILABLE`），并**必须同时给出
有效期（`period_note` / `effective_from` / `effective_to` / `stale_days`）**，
前端一律显著标成 **「非当期」** —— 有值但绝不让人误以为那是今天的费率。

- 只沿用**已经开始过**的期（`effective_from <= 查询日`）；
  若官方只公布了**尚未生效**的未来期，则**不给值**，报 `NOT_COVERED_TODAY`（下期 X 起）；
- `fallback=False` 恢复严格语义：当期没有就 `UNKNOWN`、不给值；
- 库里根本没有官方数据（来源被拦截）时**永远不给值** —— 「沿用最近一期」≠ 猜一个。

**六个来源的合法获取状态（截至 2026-10-02）**

| 承运商 | 市场 | 采集方式 | 当前燃油费 | 状态 |
|---|---|---|---|---|
| DHL | 中国大陆 | 官方网页（HTML） | 46.25% | ✅ 自动采集 |
| DHL | 中国香港 | 官方网页（HTML） | 46.25% | ✅ 自动采集 |
| UPS | 中国大陆 | 需人工导入 | 无值（不猜测） | ⚠️ 自动受限（Akamai） |
| UPS | 中国香港 | 需人工导入 | 无值（不猜测） | ⚠️ 自动受限（Akamai） |
| FedEx | 中国大陆 | 官方页面（已人工导入） | 未覆盖查询日 | ⚠️ 自动受限 |
| FedEx | 中国香港 | 需人工导入 | 无值（不猜测） | ⚠️ 自动受限（WAF） |

> **被拦截 ≠ 官方没有数据。** 六个来源里被拦的四个一律记为
> `MANUAL_REQUIRED`（"官方数据存在，但自动采集受限制，需人工导入"），
> **绝不报成"官方没有数据"**，更**绝不填一个猜测的费率**。
> 人工导入：`python run.py fuel-markets --import UPS:HK=/path/to/official.html`
> （人工导入同样归档原文 + SHA256，同样走 9 项校验，不过照样不入库）。

**内容指纹用「解析后内容」而非整页哈希**

DHL 的 AEM 页面每次请求都会变（AEM 随机表格 id、分享令牌、boomerang nonce）。
实测同一秒抓两次整页 SHA256 不同、23 行不同，但费率表完全一致。因此：

- `content_hash` = **解析后费率内容的规范化 SHA256** → 决定要不要写库；
- `raw_sha256` = 原始文件字节 SHA256 → 留证。

修复后连续两次采集：第 1 次入库 5 行，**第 2 次入库 0 行（未变化）**。

### 附加费反查的跨表聚合

「附加费反查」不只看区域型数据，而是同时聚合四类来源，因此像**燃油附加费**这类
没有邮编 / 城市的附加费也能查到官方来源，不会再出现「0 个官方来源」：

```
api/reverse  =  area_data（区域型）
             ∪  time_rates（时间变化型，含当日是否生效）
             ∪  surcharge_rules + surcharge_conditions（规则型 / 对照表）
             ∪  official_sources.surcharge_types（已登记但未产出结构化行的来源）
```

反查结果里对时间型来源会**标记「当日生效 / 尚未生效 / 已过期」**，并把当日生效的费率置顶；
过期期间默认收起但仍保留（可展开），不做静默丢弃。

同时返回 `data_shape` / `data_shape_text`（**实际拿到的数据形态**），而不是只按附加费类型笼统归类：
例如 UPS 燃油官方只公布「指数 → 百分比」对照表，就会显示「规则 / 指数对照表型」而不是「时间变化型」，
并配一段对应口径的说明（`time_rates_note`）。**口径必须与实际数据一致，不用对不上的说法糊弄人。**

实测输出示例：

```
DHL 国际快递 · 燃油附加费 | 官方来源共 1 个 | 过期收起 3
   来源: DHL 快递燃油附加费（中国官网 · 周度更新） | 时间费率
   当周费率: 46.25%  2026-09-28 ~ 2026-10-04

UPS 国际快递 · 燃油附加费 | 官方来源共 2 个
   来源: UPS 国际空运出口 / 进口燃油附加费（官方指数对照表） | 计费规则 / 对照表
   规则: 按官方燃油指数对照表（13 档 / 26 档）

FedEx 联邦快递 · 燃油附加费 | 官方来源共 1 个 | 过期收起 4
   当月费率: 43.25%  2026-09-07 ~ 2026-10-04
```

> 动态 HTML 页面（DHL / FedEx 燃油页）的字节内容每天都有细微变化（会话标识、时间戳），
> 对这些来源改用 **`hash_mode: "PARSED"` 内容指纹**：只对「解析后的费率内容」取指纹，
> 页面噪声变化不会误报「有更新」；一旦官方费率真的变了，指纹必然改变。

---

## 6. 文档

- [`docs/DEPLOY.md`](docs/DEPLOY.md) — 管理员部署说明（含定时任务与服务器同步配置）
- [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) — 普通员工使用说明
- [`docs/DATA_UPDATE.md`](docs/DATA_UPDATE.md) — 数据更新、过期策略与同步推送说明
- [`docs/FUEL_ACCEPTANCE_REPORT.md`](docs/FUEL_ACCEPTANCE_REPORT.md) — **六来源燃油附加费逐项验收报告**
- [`coverage-report.json`](coverage-report.json) — 机器可读覆盖率报告
