mpq-quote
by datu0108
README.md
# 原料报价系统
原料价格管理与原料核价的**黑盒服务** —— 采购在后台改价,业务员填参数拿报价;
所有核价口径、原料单价、计算过程锁在服务端,**AI 只能传参数进来、拿一个价格出去**。
> ## 这份代码里没有任何公司的真实数据(先看这段)
>
> 这是一个**通用模板**:结构、机制、工程约定全部保留,
> 但**任何公司的真实数字都不在里面** —— 这既是公开发布的前提,
> 也正好让它可以安全地被改造成你自己的系统。
>
> | 你需要自己填的 | 在哪填 |
> |---|---|
> | 加工费、损耗率、关键词上浮、利润率 | `app/pricing/rules.py`(模板已备好,见第七节) |
> | 原料价格表源表 xlsx | 放自己的表进 `price-tables/`,再 `python -m app.seed` |
> | 域名、服务器、部署路径 | 换成你自己的 |
>
> **填口径之前服务起不来**:`rules.py` 里 `IS_DEMO = True` 时,服务**拒绝启动**
> (fail-closed),必须显式设 `MPQ_ALLOW_DEMO_RULES=true` 才放行。
> 理由:「示例值能算出一个报价」和「示例值是对的」之间差着一个真实的商业决策 ——
> 宁可启动失败,也不要拿示例数字当真报价发出去。
---
## 一、为什么要做这个系统
把核价放在 AI 对话里完成的旧做法有两个结构性问题:
1. **价格表是 Excel**——采购改价必须靠人肉上传,AI 每次现读现查,改价无留痕、无版本。
2. **核价在 AI 对话里完成**——排版脚本只负责排版,公式是 AI 现场算的:
同一个面料两次算可能不一样,算错了没人查得出来,而且 **AI 会把加工费、损耗率、原料单价全说出来**。
这个系统把核价变成**确定性黑盒服务**:
| 层 | 谁能访问 | 能看到什么 |
|---|---|---|
| **MCP / AI 接口** | 任意 AI 客户端 | 只有**最终报价 元/kg** + MOQ 状态 + 需人工核实的提示。<br>**无任何原料单价,无任何计算步骤,无任何内部系数。** |
| **业务员 Web** | 内部业务员 | 最终价 + 原料 AA 单价/等级/匹配情况(沿用现有报价单口径),<br>**仍不含加工费、损耗率、利润率、成本。** |
| **审计明细** | 管理员本地 | 完整逐步计算过程(`view=audit`),**不通过 MCP 暴露**。 |
黑盒的实现方式(`app/pricing/public.py`):
- MCP 的工具清单里**不存在**任何能返回价格表/规则的工具(没有 `get_material_price`、没有 `list_materials`);
- 所有对外返回体走字段白名单序列化,`assert_no_internal()` 递归拦截内部字段,命中即抛错;
- 错误信息同样过滤,不带内部数字;
- MCP 令牌不对返回 **403 而不是 401**——401 会被部分 AI 客户端误判成"需要 OAuth 授权"而卡死。
---
## 二、快速开始
```bash
cd material-price-quote-system
./run.sh # 首次会自动建 venv、装依赖、导入价格表、建账号
# 打开 http://<服务器IP>:8000
```
或手动:
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python -m app.seed # 导入 price-tables/ 里的价格表 + 建账号
.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
> ### ⚠️ 第一次启动会**被拒绝**,这是故意的
>
> `app/pricing/rules.py` 里还是**示例核价口径**(`IS_DEMO = True`),服务会直接抛错退出:
>
> ```
> app/pricing/rules.py 里仍是示例核价口径(IS_DEMO=True),已拒绝启动。
> ```
>
> 两条出路,选一条:
>
> 1. **(推荐)填自己的口径** —— 把 `rules.py` 里的 `LOSS_RATE` / `BASE_FEE` /
> 关键词上浮金额 / `PROFIT_RATE` / `MOQ_UPLIFT` 换成你的数字,
> 再把 `IS_DEMO` 改成 `False`。见第七节。
> 2. **(仅本地试玩)显式放行** —— `export MPQ_ALLOW_DEMO_RULES=true`。
>
> 为什么要这么较真:「示例值能算出一个报价」和「示例值是对的」之间,
> 差着一个真实的商业决策。**宁可启动失败,也不要拿示例数字当真报价发出去。**
> 想先空跑一遍看看?`python ops/make_demo_price_table.py` 会生成一份**演示价格表**
> (编出来的假价格)放到 `price-tables/`,再 `python -m app.seed` 就能起一个有数据的实例。
> 但**别拿它当真报价** —— 里面的加工费/损耗率/利润率也还是示例值。
### 初始账号
| 账号 | 角色 | 能做什么 |
|---|---|---|
| `admin` | 管理员 | 全部 + 审计明细(`view=audit`) |
| `purchaser` | 采购 | 改价格、导入价格表、处理补价清单 |
| `sales` | 业务员 | 报价;**看不到原料单价**、看不到审计口径、不能改价 |
口令不写死在代码里,用环境变量给:
```bash
# .env
MPQ_ADMIN_PASSWORD=...
MPQ_PURCHASER_PASSWORD=...
MPQ_SALES_PASSWORD=...
# 生效(会重置已有账号的口令)
docker compose exec mpq python -m app.seed --users-only --reset-passwords
```
> 线上已用随机强口令,不再是 `admin123` / `sales123` 这类默认值。
> 同时记得改 `MPQ_SECRET_KEY` 与两个 MCP 令牌。
---
## 三、数据从哪来
Excel 原料价格表是**公司机密**,不随包分发,也不放在 AI 工作目录里
(AI 随手就能读源表 = 黑盒失效)。它应该只待在核价机能碰到的目录里,
线上服务器 `<部署目录>/price-tables/` 放一份。
安装到新机器时:把表拷进 `material-price-quote-system/price-tables/`,或用
`MPQ_PRICE_TABLE_DIR` 指向任意目录,然后 `python -m app.seed`。
导入器会**扫目录里所有 `.xlsx`** —— 不写死文件名(各家厂的表名都不一样,
写死就等于把供应商名单写进代码)。
已处理的三个数据坑:
- **单位不一致**:有的表是元/吨,有的是元/kg。导入器**按价格列表头自动判断**
(`价格(元/吨)` → ÷1000);表头没写单位时,按文件名里的「吨」字兜底。
- **同键重复行**:同一张表里可能有重复行(纤维+规格+说明+类别+工艺+等级 全同,
通常是旧表没删干净、新价追加在后面)。导入时**保留最后一行**,
所有变动写进 `price_history`,导入报告会明确列出,可人工复核。
- **空价格**:留空的行 = 待补价,会直接出现在"补价清单"里。
想先空跑一遍:`python ops/make_demo_price_table.py` 会生成一份**演示价格表**
(编出来的假价格)。
---
## 四、闭环:采购更新价格 → 业务员拿到报价
```
采购改价/导入Excel → materials 表(带 price_history 留痕)
↓
业务员填成分/规格/织机/数量 → 匹配器找价格行
↓
匹配到 → 核价引擎算 → 返回元/kg
匹配不到 → 自动登记到补价清单(price_requests)
↓
采购在"补价清单"里填价 → 写回 materials → 业务员立刻能用
```
**匹配规则**(`app/pricing/matcher.py`,对应原 `pricing-rules.md` 第 7 节):
1. 纤维与类别必须相同——纤维不同(棉 vs 涤)直接判不匹配,必须人工问价;
2. 工艺尽量相同(带同义词:`精梳紧赛`↔`紧赛`↔`紧密赛络纺`);
3. 支数/D 数最接近;
4. 距离相同时**取价高的一档**,避免低报;
5. 裸纤维查询(如业务员只说"涤纶")会**锁定主流品种**——数据驱动:同核心纤维下行数 ≥ 第二名 2 倍即认定为主流
(涤纶半光 80 行 vs 涤纶消光 30 行 → 半光)。这条规则避免"涤纶 75D DTY"被匹配到 `涤纶H400`(30.0 元/kg)而不是
`涤纶半光`(10.0 元/kg)——**差 3 倍,会直接亏钱**;
6. 同一规格有多个版本(国产/兰精LF、兰精/博拉、原棉/高配)且业务没指定 → 标记为**近似匹配**并写明取了哪一档。
**规格缺失 / 规格表里没有 时的兜底**(2026-10-22 线上事故后加的,见下):
第 4 条「距离相同时取价高」的前提是**有距离可比**。下面两种情况没有距离可言,
硬套第 4 条会直接落到该纤维**最贵**的一行,系统性高报:
| 场景 | 旧行为 | 现在 |
|---|---|---|
| 没给支数(AI 只传了 `process`) | 棉 → 60S 30.0 → **40.0**(正确 32.0,高报 25%) | 锁该纤维**最常用规格**(棉 40S)核价,标注"未指定规格,此价须人工核实" |
| 给了支数但价格表里没有(99S) | 棉 → 60S 30.0 → **42.0**(高报 31%) | 同样锁主流规格,标注"价格表里没有 99S 这一档支数/D数" |
兜底时**不再叠「取价高」**(估算值再放大一次高报),而是取最接近该组中位价的那一行。
兜底单子**不进补价清单**(缺的是规格信息不是价格,采购也不知道该补哪一档),
改为在 MCP 返回的「提示」里明确写出按哪一档估的,必须由采购/核价员核对后才能对外报价。
另外两条防坑:
- **规格回填**:AI 常把支数写在面料名里("32S全棉精梳汗布")却没填 `spec` 字段。
系统会从面料名/需求原话里把支数捞回来再匹配(只有一种原料时才回填,避免把棉的 40S 套到氨纶 40D 上)。
实测:回填后那张单从 40.0 变回 **32.0**。
- **参数不完整 ≠ 价格缺失**:没给纤维、类别填反这类错误会明确报「参数不完整」,
**不会**登记进补价清单、**不会**编一个价格。类别填反时系统会自动纠正并告知(棉被当长丝类 → 纠正为短纤类)。
---
## 五、管理后台(采购 / 老板)
`https://quote.example.com/admin.html` —— 只有**采购**和**管理员**能进,
业务员访问会被挡下(接口层同样 403,不是只在前端藏)。
| 标签页 | 给谁 | 干什么 |
|---|---|---|
| **概览** | 老板 | 原料总数/有价/待补价/待处理补价单/报价单数/近 7 天改价数/价格表更新至 + 分类别待补价占比 + 最近一次改价 |
| **原料更新** | 采购 | 改价(改过的行标黄,可一次批量提交)、清空价格转待补价、改价格日期、导入 Excel |
| **补价流程** | 采购 | 待补价 → 填价生效 / 忽略 / 重新打开;价格表空价行也一并列出 |
| **变更记录** | 采购 / 老板 | 价格变更流水:谁、何时、哪个原料、旧价 → 新价、涨跌幅、来源文件。**这就是"流程更新"的留痕** |
| **报价记录** | 老板 | 所有经系统核价的报价留档(单号/客户/布名/织机/数量/报价/是否低于 MOQ/业务员/时间) |
| **核价口径** | **仅管理员** | 等级系数、损耗率、各织机加工费、关键词上浮、工艺别名、MOQ 与上浮规则 —— 机密,仅老板可见 |
新增接口(都在 `app/api/admin.py`):
| 方法 | 路径 | 权限 |
|---|---|---|
| GET | `/api/admin/stats` | 采购 / 管理员 |
| GET | `/api/price-history` | 采购 / 管理员 |
| POST | `/api/materials/batch-update` | 采购 / 管理员 |
| PATCH | `/api/price-requests/{id}/status` | 采购 / 管理员 |
| GET | `/api/admin/rules` | **仅管理员** |
> 批量改价与单条改价一样,每一行都写 `price_history` 留痕。
> 「核价口径」里的加工费/损耗率/利润系数只在这个页出现,绝不进任何对外返回体。
---
## 六、接给 AI 用(MCP)
### 端点
```
https://quote.example.com/mcp
Authorization: Bearer <令牌>
```
支持 Streamable HTTP(Accio Work 等填 URL 的客户端)与 stdio(Claude Desktop 等本地客户端)。
### 两种令牌:管理员 / 业务员
令牌与角色一一对应,**可以单独吊销,互不影响**。
| 令牌 | 环境变量 | 角色 | 能拿到什么 |
|---|---|---|---|
| 管理员 | `MPQ_MCP_TOKEN` | `admin` | 默认黑盒;可显式传 `view=business` 拿含 AA 单价的业务口径(内部核对用),`list_material_options` 给 400 条 |
| 业务员 | `MPQ_MCP_TOKEN_SALES` | `sales` | **只有最终报价**。传 `view=business` 会被降级并明确告知;词表只给 200 条 |
> 业务员令牌**永远拿不到原料单价**,无论怎么构造参数。
> 令牌不对一律返回 **403**(不是 401 —— 401 会让部分 AI 客户端误判为"需要 OAuth 授权"而卡死)。
换令牌只要改服务器 `.env` 再 `docker compose restart`:
```bash
# 重新生成业务员令牌
python3 -c "import secrets;print(secrets.token_urlsafe(32))"
# 写进 .env 后
docker compose up -d
```
### 工具清单
| 工具 | 作用 | 返回 |
|---|---|---|
| `quote_material_price` | **核价** | 只有最终报价 + 提示 |
| `list_machines` | 可选织机与常用布种 | 织机名、常用布种(**无费用**) |
| `list_material_options` | 原料规格词表 | 纤维/规格/工艺(**无价格**) |
`list_material_options` 返回的条目**字段名与 `quote_material_price` 的 materials 入参完全一致**
(`fiber` / `spec` / `process` / `desc` / `alias`),AI 可以**原样照抄**进 materials 数组。
早期版本返回中文键(`纤维` / `支数或D数` / …),而工具入参是英文键,
Pydantic 默认 `extra="ignore"` 会**静默丢弃**中文键 → materials 项全空 → 核价直接失败
(线上真实事故:AI 照抄词表后报错,业务被误导去"采购补价",而价格表里明明有)。
现在词表输出英文键,同时 `MaterialInput` 也**接受中文键别名**,两边怎么写都能用。
`list_material_options` 可以在环境变量里关掉(`MPQ_MCP_EXPOSE_SPEC_VOCABULARY=false`),
关掉后 AI 只能凭业务员的说法尝试匹配,命中率会下降。
### 调用示例
```json
{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "quote_material_price",
"arguments": {
"raw_requirement": "32S全棉精梳汗布,3000公斤",
"materials": [
{"fiber": "全棉", "category": "短纤类", "spec": "32S", "desc": "精梳", "grade": "AA", "ratio": 1.0}
],
"machine": "单面圆纬机",
"quantity_value": 3000, "quantity_unit": "kg",
"fabric_name": "全棉汗布", "customer": "XX客户"
}
}
}
```
> `raw_requirement` 务必原样传入业务员的原话 —— 系统据此判断品质等级/工艺这些参数
> 是业务员明确说的,还是 AI 自己补的(补的会在"提示"里要求人工确认)。
返回:
```json
{
"报价单号": "MQ20261022-0001",
"初步报价_元每kg": 30.0,
"报价口径": "人民币元/kg,含税;不含运费、包装费;面料价(不含染色印花等后整理)",
"报价基准": "按 MOQ 报价",
"提示": [],
"折算重量_kg": 3000.0,
"原料价格更新至": "2026-10-17"
}
```
匹配不到价格时返回 `{"成功": false, "错误": "...已登记到补价清单..."}`,**不会编一个价格出来**。
参数不全(例如没给支数/D数)时返回"请补充规格后再报价",**不会**登记进补价清单 ——
参数没给全是业务员的事,不是采购的补价待办。
### 客户端配置
**Accio Work**(填 URL + 请求头):
```
URL: https://quote.example.com/mcp
Header: Authorization: Bearer <MPQ_MCP_TOKEN_SALES>
```
**Claude Desktop**(`claude_desktop_config.json`):
```json
{
"mcpServers": {
"mpq-quote": {
"command": "/绝对路径/material-price-quote-system/.venv/bin/python",
"args": ["-m", "app.mcp_server"],
"cwd": "/绝对路径/material-price-quote-system",
"env": { "MPQ_MCP_TRANSPORT": "stdio" }
}
}
}
```
---
## 七、核价口径(内部,勿外传)
> 以下内容属于公司内部定价机密,**不通过任何对外接口返回**。本文档仅供系统维护者查阅。
>
> ⚠️ 下面给的是**示例值**(对应 `app/pricing/rules.py` 里的 `IS_DEMO = True`)。
> **必须换成你自己的数字** —— 加工费、损耗率、上浮金额、利润率都是商业机密,
> 开源版只能给结构和占位数字。
公式(每 kg,人民币,含税):
```
等级调整后单价 P = AA单价 × 等级系数 A=0.95 AA=1.00 AAA=1.05 AAAA=1.10
原料成本 = Σ(占比 × P)
织造损耗 = Σ(占比 × P × 损耗率) 短纤类 5% 长丝类 3% ← 示例值
加工费 = 基础加工费(织机, 场景) + Σ关键词上浮 ← 示例值
报价成本 = 原料成本 + 织造损耗 + 加工费
MOQ 报价 = 报价成本 × 1.10,向上进位到 1 位小数 ← 示例值
低于 MOQ = MOQ 报价 × 1.02,再向上进位 (MOQ = 2000kg) ← 示例值
```
- 场景:含任意短纤类原料 → "带短纤"加工费表与关键词表;全部长丝 → "全长丝"表。
- 加工费、损耗率、关键词上浮金额、利润率:**禁止出现在任何对外返回体**。
- 数量为米/码时必须给全幅(cm)与 GSM;1 码 = 0.9144 米;`每kg米数 = 1000 ÷ (GSM × 全幅米)`。
- 数量不明按 MOQ 报价。
- 对外统一说法必须和算法**一致**:示例是「MOQ 2000kg,低于 2000kg 在 MOQ 报价基础上上调 2%」——
业务员嘴上说的和系统算出来的如果不是一回事,扯皮的就是你。
**改口径的四处**(都在 `app/pricing/rules.py`):
`LOSS_RATE` / `BASE_FEE` / `SPUN_KW`·`FIL_KW` 里的金额 / `PROFIT_RATE`·`MOQ_UPLIFT`。
改完把 `IS_DEMO` 改成 `False`,服务才会正常启动。
这套口径有**两套独立实现互相对照**:`tests/test_engine.py` 里另写了一份按公式重推的参考实现,
逐项和 `engine.compute_quote()` 比对(覆盖短纤/长丝/低于MOQ/米码换算/关键词叠加/等级调整/数量未知/面料名命中),
两边不一致就红。
---
## 八、REST API
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| POST | `/api/auth/login` `/logout`,GET `/api/auth/me` | 登录态 | — |
| GET | `/api/materials` | 价格表(分页/筛选) | 登录 |
| PATCH | `/api/materials/{id}` | 改价格(留痕) | 采购/管理员 |
| GET | `/api/materials` | 原料价格表 | 登录(**业务员看不到 `price` 值**) |
| POST | `/api/materials/import` | 导入 Excel | 采购/管理员 |
| GET | `/api/materials/vocabulary` | 规格词表(**无价格**) | 登录 |
| GET | `/api/price-requests` | 补价清单(**业务员看不到 `price` 值**) | 登录 |
| POST | `/api/price-requests/{id}/fill` | 补价 | 采购/管理员 |
| POST | `/api/quotes/price` | 试算(`view` = business/ai/audit) | 登录 |
| POST | `/api/quotes` | 报价并存档 | 登录 |
| GET | `/api/quotes` | 报价历史 | 登录 |
`view=ai` 的返回与 MCP 完全一致;`view=audit` 含完整计算过程,**仅管理员可见**
(业务员伪造 `view=audit` 直接 403)。
**业务员默认看不到任何单价**(`MPQ_SALES_CAN_SEE_PRICE=true` 可打开)。
价格表里凡是自由文本(匹配说明、补价备注、错误信息)在返回给业务员前都会过一遍
`scrub_prices()` 清洗,防止价格数字混在说明文字里漏出去。
交互式文档:`http://<服务器IP>:8000/api/docs`
---
## 九、部署
**示例部署**:Docker Compose + Nginx 反代 + Let's Encrypt 证书。下面是一套可直接照抄的骨架,
路径和域名请换成你自己的。
### 服务器上的目录结构
```
/opt/material-price-quote-system/ 应用目录
├── app/ web/ tests/ price-tables/
├── .env 权限 600,密钥在服务器本地生成
├── data/mpq.db SQLite(volume 挂载到容器 /app/data)
├── Dockerfile docker-compose.yml
/etc/letsencrypt/live/<你的域名>/ 证书(certbot 自动续期)
/etc/nginx/conf.d/<你的域名>.conf nginx vhost
/var/www/<你的域名>/.well-known/acme-challenge/ 证书签发 webroot(权限必须 755,否则 403)
```
> `price-tables/` 里的源表**不进镜像**(`.dockerignore` 已排除),运行时只读 bind mount 挂进容器。
> 验证方法见 `AGENTS.md`「机密源表怎么验证它真的没进镜像」。
### 常用操作
```bash
cd /opt/material-price-quote-system
docker compose ps # 状态
docker compose logs -f --tail 100 # 日志
docker compose restart # 重启
docker compose up -d --build # 改完代码后重新部署
docker compose exec mpq python -m app.seed --users-only # 只建账号
docker compose exec mpq python -m app.seed # 重导价格表 + 建账号
```
### 为什么用 Docker
本项目需要 Python 3.10+(`X | None` 注解 + pydantic 2 + MCP SDK 2),很多机器的系统 Python 还停在 3.8,
所以用 `python:3.12-slim` 容器。**国内拉不动 Docker Hub 时**把基础镜像换成国内加速前缀,
pip 也换成国内源(见 `Dockerfile`,那里已写成标准源 + 注释说明怎么换)。
容器内存限制 400MB(`mem_limit` + `memswap_limit`):这台机器上往往还跑着别的东西
(比如数据库常驻几百 MB),不给容器设上限,它被挤爆时会**连坐**把整台机的内存吃光。
超了只杀自己,不拖累邻居。
> 实测过的一个坑:宿主机 1.9G 内存 + mysqld 常驻 470M,容器不限内存时跑着跑着
> 整机被 OOM killer 干掉,MySQL 先死。**容器一定要设内存上限。**
### 换到其他服务器
```bash
# 1. 上传代码
cd /opt/material-price-quote-system
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
# 2. 配 .env(密钥自己生成,别用示例值)
cat > .env <<'EOF'
MPQ_DB_URL=sqlite:////app/data/mpq.db
MPQ_SECRET_KEY=<随机长串>
MPQ_MCP_TOKEN=<随机长串>
MPQ_MCP_ENABLED=true
MPQ_MCP_EXPOSE_SPEC_VOCABULARY=true
MPQ_PRICE_TABLE_DIR=/opt/material-price-quote-system/price-tables
EOF
chmod 600 .env
# 3. 初始化并启动
.venv/bin/python -m app.seed
docker compose up -d --build
# 4. nginx + 证书
mkdir -p /var/www/<你的域名>/.well-known/acme-challenge
chmod 755 /var/www/<你的域名> /var/www/<你的域名>/.well-known
chown -R www-data:www-data /var/www/<你的域名>
# 先写一个只监听 80 的 vhost,certbot --webroot 签完再换成 HTTPS
certbot certonly --webroot -w /var/www/<你的域名> -d <你的域名> \
--register-unsafely-without-email --agree-tos --non-interactive
```
nginx vhost 见 `deploy/quote.example.com.conf`(本仓库内),要点:
- `/` 反代到 `127.0.0.1:8010`;
- **`/mcp` 必须单独一个 location**:`proxy_buffering off` + `proxy_read_timeout 3600s`,
否则 SSE 响应会被缓冲,AI 客户端会卡住;
- 容器只绑 `127.0.0.1`,对外只暴露 443,避免绕过 TLS。
### 换成 MySQL
```bash
.venv/bin/pip install pymysql
# .env 里改成
MPQ_DB_URL=mysql+pymysql://mpq:密码@127.0.0.1:3306/mpq?charset=utf8mb4
```
> 宿主机上已有 mysqld(3306)时,**不要**给本系统单独起一个 MySQL 容器 ——
> 两个 MySQL 抢内存是最容易把自己搞挂的方式。
### 已做的安全收口
| 项 | 做法 |
|---|---|
| OpenAPI 文档 | 默认关闭(`MPQ_ENABLE_API_DOCS=true` 才开),避免暴露接口结构 |
| 登录爆破 | 同 IP 10 分钟内失败 8 次即 429 |
| 账号枚举 | 用户不存在与密码错误返回同一个提示 |
| MCP 令牌不对 | 返回 **403**(不是 401),避免 AI 客户端误判为需要 OAuth |
| 令牌分级 | 管理员 / 业务员两个独立令牌,业务员令牌永远拿不到单价,可单独吊销 |
| REST 审计口径 | `view=audit` 仅管理员,非管理员伪造即 403 |
| 价格表读取 | 业务员拉全量价格表拿不到 `price` 值,只拿 `has_price` 布尔 |
| 自由文本 | 说明/备注/错误信息返回给业务员前过 `scrub_prices()` 清洗价格数字 |
| 管理后台 | `/admin.html` 与 `/api/admin/*` 只对采购/管理员开放,业务员一律 403 |
| 核价口径 | 加工费/损耗率/利润系数只在 `/api/admin/rules`(**仅管理员**)出现,不进任何对外返回体 |
| 静态文件 | `/.env`、`/app/*.py`、未知路径全部 404 |
| 业务接口 | 未登录一律 401 |
| 证书 | Let's Encrypt,certbot 自动续期,续期后自动 reload nginx(见 `deploy/renewal-hook.sh`) |
| 容器 | `mem_limit 400m`、`restart: unless-stopped` |
## 十、测试
测试分两层:**零依赖的**(全新克隆就能跑)和**需要自建环境的**。
### 第一层:零依赖,开箱即跑
```bash
.venv/bin/python -m tests.test_engine # 引擎口径 vs 测试内的独立参考实现,逐项比对 + 黑盒泄露检测
.venv/bin/python -m tests.test_matcher # 匹配器回归:合成价格表 + 业务问法钉死命中行
```
### 第二层:需要先起服务 / 需要凭据
```bash
.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8077 &
.venv/bin/python -m tests.test_e2e # REST + MCP 端到端(含泄露检测、权限边界)
```
这一层会连真实服务、可能改数据,口令和令牌**必须用环境变量传**
(`MPQ_ADMIN_PASSWORD` / `MPQ_MCP_TOKEN` / `E2E_BASE_URL` …),
完整清单见 `AGENTS.md` 的「测试」一节。
---
## 十一、目录结构
```
material-price-quote-system/
├── app/
│ ├── main.py # 单进程:REST + Web + MCP(含示例口径 fail-closed 护栏)
│ ├── mcp_server.py # MCP 工具定义(对外唯一出口)
│ ├── config.py db.py models.py security.py
│ ├── pricing/
│ │ ├── rules.py # ★机密:加工费/损耗/上浮/利润率(模板,默认是示例值)
│ │ ├── engine.py # 核价引擎
│ │ ├── matcher.py # 原料价格匹配器
│ │ └── public.py # ★黑盒网关:三种可见级别的白名单输出
│ ├── services/
│ │ ├── importer.py # Excel 导入(单位换算 + 去重 + 留痕)
│ │ └── quote_service.py # 报价编排 + 补价登记
│ └── api/ # auth / materials / quotes / admin(管理后台)
├── web/ # 零依赖原生 JS,不引 CDN
│ ├── index.html # 报价计算器
│ ├── materials.html # 价格表管理 + 补价清单
│ ├── admin.html # 管理后台(采购/老板):原料更新·补价流程·变更记录·报价记录·核价口径
│ ├── common.js app.css
├── price-tables/ # 放你自己的原料价格表(不随包分发,见第三节)
├── ops/ # 备份 / 令牌取用 / 权限收口 / 演示价格表生成器
├── deploy/ # nginx vhost 示例 + 证书续期 hook
├── tests/ # 见第十节
├── data/mpq.db # SQLite(生产换 MySQL)
├── run.sh requirements.txt .env.example
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues