# domain-rules

> 业务领域规则。来源：查价系统 **PRD v0.6** + **技术设计 v1.2**。与 PRD 冲突时以 PRD 为准。  
> 工程任务权威清单：`docs/查价系统-工程任务拆解-v1.2.md`。

## 1. 系统定位与边界

| 项 | 规则 |
|----|------|
| 产品名 | 美美与共报价中台（查价系统） |
| 报价源 | Mothership，**仅 RPA**，不使用 Mothership API |
| 嵌入形态 | 查价 UI 为**宿主内嵌组件/API**；中台仅 **admin 管理端**需登录 |
| 本期不含 | CC 对接、独立客户/运营登录、多币种、下单、对账、Priority1 |
| 交付周期 | 3 周；MVP |

## 2. 角色与鉴权

| 角色 | 鉴权方式 | 权限 |
|------|----------|------|
| 宿主系统 | Service Token / API Key（Bearer） | 代终端用户询价、查历史、配置加价；请求体携带 `customer_id` |
| 管理员 | JWT + RBAC | `alert:read`、`rpa:operate`；预警/RPA/开发进度 |

- 查价 API：`Authorization: Bearer <宿主 token>`；中台校验 token 与租户绑定，越权 403
- 管理端 API：管理员 JWT，与宿主 token 隔离
- **不提供** `customer_demo` / `operator_demo` 独立业务账号
- Demo：宿主模拟 `customer_id` + 管理端 `admin_demo`（Demo@123）

## 3. 全局收敛决策（GD-1 ~ GD-15）

| 编号 | 规则 |
|------|------|
| GD-1 | 一次询价 = 1 次 RPA；单次返回 **4 档**（standard/guaranteed × lowest/fastest） |
| GD-2 | 缓存优先级：L1 `request_id`(24h) > L2 `cargo_hash`(3min) > L3 stale(30min，仅 RPA 失败降级) |
| GD-3 | `service_level` / `rate_option` **不进入** cache key |
| GD-4 | 成功率拆三指标：API success / RPA success / realtime rate |
| GD-5 | 异步模型：`POST /quotes` 返回 `quote_id`；客户端轮询 `GET /quotes/{quote_id}`（2s，最长 30s） |
| GD-6 | 加价仅对 `raw_freight` 按百分比；上限 30%；未配置客户 0% |
| GD-7 | 本期仅 USD |
| GD-8 | 货物类型固定 9 项枚举（见 §4） |
| GD-9 | 地址须从联想列表点选；须 `place_id` + `selected_from_suggestions=true` |
| GD-10 | 金额 `DECIMAL(12,2)`，`ROUND_HALF_UP` |
| GD-11 | 查价嵌入宿主；中台无运营加价 UI（宿主调 API） |
| GD-12 | 使用 `pallet_count`（托盘数），禁止件数/箱数 |
| GD-13 | API 可收 kg/cm；落库与 RPA 前强制换算 lb/in |
| GD-14 | 尺寸重量上限以 Mothership Help Center 公布值为硬校验 |
| GD-15 | `MOTHERSHIP_QUOTE_URLS` 须 headed 录制验证的匿名报价 URL 列表；**禁止**默认 dashboard / `/quote` |

## 4. 询价输入口径

### 4.1 地址（pickup_address / delivery_address）

- 必填：`street`、`city`、`state`、`zip`、`place_id`、`formatted_address`
- `selected_from_suggestions` 必须为 `true`
- RPA 须点击联想项，禁止仅填街道后 Enter

### 4.2 重量与尺寸（单托）

- 重量：换算后 **0.01–9,999** lb
- 尺寸（in）：length 1–999、width 1–99、height 1–99
- 整票总重：`weight_lb × pallet_count` ≤ 249,975 lb

### 4.3 托盘数（pallet_count）

- 正整数，范围 **1–25**
- 映射 Mothership `freight[].quantity`（type=Pallet）
- **禁止** `quantity` 件数字段

### 4.4 货物类型（cargo_type）

```
general_freight | machinery | furniture | electronics | building_materials
auto_parts | food_nonperishable | apparel | other
```

默认 `general_freight`。

### 4.5 cargo_hash

```
MD5(pickup + delivery + weight_lb + dims_in + pallet_count + cargo_type)
```

不含 `service_level`、`rate_option`、`customer_id`。

## 5. 询价输出口径

- 一次返回 **4 档**报价
- 默认展示：`service_level=standard` + `rate_option=lowest`（仅 UI 默认 Tab）
- `status`：`processing` | `done` | `failed` | `expired`
- `source`：`cache` | `rpa` | `stale`
- `is_realtime`：L3 降级时为 `false`
- 报价有效期：3 分钟（`valid_until`）

## 6. 加价规则

```
markup_amount = ROUND_HALF_UP(raw_freight × markup_percent / 100, 2)
final_total   = ROUND_HALF_UP(raw_total + markup_amount, 2)
```

| 规则 | 说明 |
|------|------|
| BR-6.1 | 写操作需 `pricing:markup:write` |
| BR-6.2 | `markup_percent ∈ [0, 30.0]`，步长 0.1 |
| BR-6.3 | 未配置客户按 0% |
| BR-6.4 | 配置变更仅影响新询价，历史不重算 |

## 7. 报价状态机

```
[processing]
  → L1 命中 → [done]
  → L2 命中 → [done, source=cache]
  → 未命中 → 入 RPA 队列
       → RPA 成功 → [done, source=rpa]
       → RPA 失败 → L3 命中 → [done, source=stale, is_realtime=false]
                    → L3 未命中 → [failed, QUOTE_UNAVAILABLE]
  → 30s 未完成 → [failed, QUOTE_TIMEOUT]
[done] --3min--> [expired]
```

## 8. 缓存规则

| 层 | Key | TTL | 内容 |
|----|-----|-----|------|
| L1 | `idem:{request_id}` | 24h | 完整响应快照（含加价后结果） |
| L2 | `quote:{cargo_hash}` | 3min + jitter(0~30s) | 四档原始报价（未加价） |
| L3 | `stale:{cargo_hash}` | 30min | 同 L2，仅 RPA 失败降级只读 |

读取顺序：L1 → L2 → RPA →（失败）L3。L3 永不用于正常路径。

## 9. 预警类型

| type | 触发 |
|------|------|
| PRICE_DEVIATION | RPA 结果与 L3 历史偏差 ≥ 5% |
| STALE_FALLBACK | 使用 L3 降级返回 |
| RPA_FAILED | RPA 失败且无 stale |
| RPA_CAPTCHA | 遇验证码；暂停该 Worker |

## 10. 错误码

| error_code | 含义 |
|------------|------|
| VALIDATION_FAILED | 入参校验失败 |
| QUOTE_UNAVAILABLE | RPA 失败且无 stale |
| QUOTE_TIMEOUT | 30s 内未完成 |
| QUOTE_NOT_FOUND | quote_id 不存在 |
| FORBIDDEN | 越权/无权限 |
| RATE_LIMITED | 触发限流 |
| CARRIER_NO_CAPACITY | Mothership 无运力 |
| ADDRESS_NOT_SUPPORTED | 邮编不支持 |
| ADDRESS_SUGGESTION_NOT_FOUND | RPA 地址联想点选失败（v0.6） |
| QUOTE_ENTRY_UNAVAILABLE | 所有报价入口 URL preCheck 失败（v0.6） |
| INTERNAL_ERROR | 系统异常 |

## 11. API 契约

统一响应：`{ "code": 0, "message": "ok", "data": {...} }`

| 方法 | 路径 | 权限 |
|------|------|------|
| POST | /api/quotes | 宿主 Service Token |
| GET | /api/quotes/{quote_id} | 宿主（本人 customer_id） |
| GET | /api/quotes/history | 宿主（本人 customer_id，分页） |
| PUT | /api/markup-configs/{customer_id} | 宿主（pricing:markup:write） |
| GET | /api/alerts | 管理员 |
| POST | /api/alerts/{id}/resolve | 管理员 |

## 12. 数据表（Prisma 实体）

`SysUser` · `QuoteRecord` · `IdempotencyRecord` · `QuoteCacheMeta` · `MarkupConfig` · `AlertLog` · `AuditLog`

- 金额字段：`DECIMAL(12,2)`
- 删除：逻辑删除（`is_deleted`）
- DB 变更须 migration，禁止代码先行引用未存在字段

## 13. 样例数据（联调/测试）

| 项 | 值 |
|----|-----|
| customer_id | `CUST_001` |
| request_id | UUID v4，如 `550e8400-e29b-41d4-a716-446655440000` |
| 路线 | LA → Dallas |
| 单托重量 | 500 lb |
| 单托尺寸 | 48×40×48 in |
| 托盘数 | 2 |
| 货物类型 | `general_freight` |
| 加价示例 | 10%：raw_freight=320 → final_total=352（无附加费） |
| 管理端账号 | `admin_demo` / `Demo@123` |

## 14. 废止口径（v1.1 冻结）

- task-075~078 **废止**：独立客户查价页、运营登录、运营加价 UI
- task-106 **替代** task-075：宿主内嵌查价组件
- 四档报价以 GD-1/TC-102 为准（非 2 档）

## 15. v1.2 增量任务（task-119~135）

> v1.1 基线 task-001~118（除废止）**已完成**；以下按 `工程任务拆解-v1.2.md` 顺序交付。

| Phase | Task | 目标 |
|-------|------|------|
| 0（增量） | **119→120** | RPA env 模板（GD-15）+ docker `rpa_state` volume |
| 5.0 | 121→122 | headed 录制 SOP + selector 提取 |
| 5 | 123→131 | QuotePageAdapter、storageState、selector env 化、错误分类、probe 3/3 |
| 8 | 132 | `/admin/queues` Bull Board |
| 9 | 133→135 | TC-601 修订 + Go/No-Go probe 门禁 + CI |

### RPA 环境变量（task-119，GD-15）

| 变量 | 必填 | 说明 |
|------|------|------|
| `MOTHERSHIP_QUOTE_URLS` | **是** | 逗号分隔匿名报价入口 URL，按序探测 |
| `RPA_STORAGE_STATE_PATH` | 否 | 默认 `.rpa/mothership-storage.json` |
| `RPA_SELECTOR_*` | **是**（8 项） | 录制产出，禁止硬编码 selector |
| `RPA_USE_PATCHRIGHT` | 否 | 可选 Patchright 驱动 |

### RPA 错误分类（v0.6）

| 类型 | 典型场景 | 错误码 | 重试 |
|------|----------|--------|------|
| 入口错误 | URL 404、login 无凭据、preCheck 无表单 | `QUOTE_ENTRY_UNAVAILABLE` / `STRUCT_CHANGE` | 否 |
| 地址未锁定 | 联想点选失败 | `ADDRESS_SUGGESTION_NOT_FOUND` | 否 |
