L-20260823-02 MFR 开放报价功能补全执行
任务编号:L-20260823-02 / 执行人:听码 WDE / 日期:2026-08-23
任务来源:用户指令"执行 MFR 开放报价功能的 10 项补全,使其达到最小可部署状态"
依据报告:dip1/docs/worklog/wdl/L-20260823-01_mfr_quote_completion_audit.md(同日先期核查)
范围:dip1/ 本地代码(后端 QSV + 前端 mfr-portal + 数据 + 部署)
约束:只改 dip1/;不 git push/pull;最小化原则;保持代码风格一致
1. 任务起止与输入产物
- 开始/完成:2026-08-23
- 输入产物:核查报告 L-20260823-01 §6 补全清单 10 项(P0×4 + P1×5 + P2×1)
- 输出产物:
- 后端 6 个文件改动 + 2 个新文件 + 1 个新测试文件 + 1 个新数据文件 + 1 个新 seed 包
- 前端 4 个新文件 + 2 个文件改造
- 本日志
2. 补全执行结果(逐项)
P0 阻塞项(4/4 ✅)
P0-1 修复 MFR 路由路径 ✅
- 改动:
dip1/backend/app/interfaces/api/qsv_router.py L106
- 路径
POST /api/v2/qsv/quote/{quote_id}/mfr-open → POST /api/v2/qsv/quote/mfr-open
- 删除函数签名中无用的
quote_id: str 参数
- 同步:
dip1/backend/tests/api/test_qsv_quote.py L163 测试路径更新为正确路径,同时新增 PDF 端点 GET 断言
- 状态:测试
test_mfr_open_quote 通过,含 PDF 端点冒烟断言
P0-2 修复 PPL ORM 类名 bug ✅
- 改动:
dip1/backend/app/application/qsv/adapters.py L182
"MdsPplBrand" → "MdsPplProducer"(对齐 infrastructure/db/models/mds.py L183 实际类名)
- 追加修复(任务描述未提但功能必需):查询字段
"ppl_id" → "id"
- 理由:
MdsPplProducer ORM 主键列名为 id,非 ppl_id;只改类名不改 where 列,仍查不到
- 影响:MFR 门户 ppl_id Header 注入后能正确读到 PPL 表 brand_multiplier,而非永远走档位回落
- 回归:
tests/api/test_mds_supply.py 22 项全过,无回归
P0-3 补 PDF 端点 ✅
- 新增:
dip1/backend/app/application/qsv/pdf.py(reportlab 渲染)
- 新增端点:
GET /api/v2/qsv/quote/{quote_id}/pdf(qsv_router.py)
- 调
svc.get_quote(quote_id) → render_quote_pdf(snapshot) → Response(media_type="application/pdf")
- Content-Disposition:
attachment; filename="quote_{quote_id}.pdf"
- 依赖:
dip1/backend/pyproject.toml 新增 reportlab>=4.0(纯 Python,无系统依赖,符合"最小资源"原则)
- PDF 内容(对齐任务要求 6 项):
- 报价单号(quote_id + version)
- MFR 信息(ppl_id(从 Header claim 推断)+ 品牌档位 + 品牌溢价系数)
- 产品明细(SCL 品类代码 / 工时 / 费率 / 产品代码 / P2 参数摘要)
- 报价金额(subtotal_steps 9 步明细 + final_amount 高亮)
- 有效期(计算时间 +30 天)
- QSV 引擎版本(engine_version + rule_engine_hint + DME 模式 + L4 锁定状态)
- CJK 字体:reportlab 内置 STSong-Light(Adobe Asian 字体包,无需字体文件);注册失败回落 Helvetica
- 测试:
test_mfr_open_quote 新增 PDF 端点断言(%PDF magic + content-type + content-disposition)
P0-4 补 dev token 端点 ✅
- 新增:
dip1/backend/app/interfaces/api/dev_router.py
- 路由
POST /api/v2/dev/token(prefix /api/v2/dev)
- 入参
DevTokenIn{sub, ppl_id?},出参 DevTokenOut{access_token, token_type="bearer"}
- 默认签发
MFR-USR 角色;ppl_id 存在则写额外 claim
- 调用
make_dev_token()(已有 security.py L88-93)
- 挂载控制:
dip1/backend/app/interfaces/api/__init__.py mount_routers()
if settings.is_dev_auth: 时挂载 dev_router(生产由 Keycloak 替代,不暴露)
- 新增测试:
dip1/backend/tests/api/test_dev_token.py(3 项:full / minimal / validation)
P1 前端必需项(5/5 ✅)
P1-5 前端基础设施 ✅
- 新增
dip1/frontend/apps/mfr-portal/lib/auth.ts:参考 opr-admin,TOKEN_KEY 沿用 dip1.jwt;额外加 parseToken() 解析 JWT payload(供前端展示 ppl_id claim)
- 新增
dip1/frontend/apps/mfr-portal/lib/api.ts:复用 @dip1/shared Dip1ApiClient;导出 PplOption / QuoteResultOut / DevTokenIn / DevTokenOut 类型
- 新增
dip1/frontend/apps/mfr-portal/app/globals.css:参考 opr-admin 品牌色(#004046 深青 / #D4AF78 金)+ 表单/卡片/按钮/结果表样式
- 改造
dip1/frontend/apps/mfr-portal/app/layout.tsx:import "./globals.css",移除 inline body style(统一由 globals.css 控制)
P1-6 改造 login 页面 ✅
- 改造
dip1/frontend/apps/mfr-portal/app/login/page.tsx:
- 双登录方式:① dev token 自助签发(sub + 可选 ppl_id → POST /api/v2/dev/token);② 粘贴已有 JWT
- 默认 sub=
mfr-test;签发成功跳 /quote
- 注册入口 Link → /register
- 错误态显示:404/403/422 时提示"端点仅开发模式可用"
P1-7 新增 register 页面 ✅
- 新增
dip1/frontend/apps/mfr-portal/app/register/page.tsx:
- 个人注册表单:用户名 / 密码 / 手机号(必填)
- 可选绑定 MFR:input + datalist(PPL 下拉端点
/api/v2/mds/supply/cpt/ppl-options 需 ANY_ROLE 鉴权,未登录会 401 → 前端 catch 后降级为手填 input)
- 决策点:避免给 mds_router 加公开端点侵入;datalist 兼顾"已登录下拉/未登录手填"两种场景
- V1 占位:注册成功 = 通过 dev token 端点签发 MFR-USR token(V2 接 Keycloak 真注册流)
P1-8 新增 quote 报价页面 ✅
- 新增
dip1/frontend/apps/mfr-portal/app/quote/page.tsx(核心页面):
- 精简输入:产品类型(8 品类下拉)/ 工时 / 型号 / 数量 / 安装地址 / 楼宇类型(4 类下拉)/ 区域(4 区下拉)/ 品牌档位(3 档下拉)
- 字段映射(前端精简 → 后端 32 因子):
- 产品类型 → P1.scl_code(8 品类 → SCL-XXX-001)
- 工时 → P1.estimated_hours
- 型号 → P2.product_code
- 数量 → P2.quantity(仅前端展示,不参与计价)
- 安装地址 → P5.delivery_addr.addr_text(GPS 用香港中心占位坐标 22.3193,114.1694)
- 楼宇类型 → P3.building_type_coef(PUBLIC_HDB 1.0 / PRIVATE 1.05 / VILLAGE 1.15 / OFFICE 1.1)
- 区域 → P4.area_code
- 品牌档位 → P8.ppl_brand_tier
- P0.project_id 自动生成
MFRQ-yyyyMMdd-NNNN(4 位随机数)
- 直接 fetch(非 Dip1ApiClient.post):因 mfr-open 路由要求
ppl_id Header,shared client.post 不支持自定义 header;fetch 携带 Authorization + ppl_id
- 结果展示:subtotal_steps 9 步明细表 + final_amount 高亮行 + meta(quote_id/version/dme/engine_version/calculation_at)
- PDF 下载按钮:fetch GET /pdf → blob → 触发浏览器下载
quote_{quote_id}.pdf
- 顶栏:品牌 + 报价链接 + 退出(清 localStorage)
P1-9 改造首页 page.tsx ✅
- 改造
dip1/frontend/apps/mfr-portal/app/page.tsx:
- client component;首屏 useEffect 检查 localStorage JWT
- 未登录 →
router.replace("/login");已登录 → router.replace("/quote")
- dev JWT 存 localStorage,SSR 读不到,必须 client 组件首屏重定向
- 跳转中显示"跳转中…"占位
P2 数据项(1/1 ✅)
P2-10 补 PPL 种子数据 ✅
- 新增
dip1/backend/data/ppl_seed.json:3 档示例生产商
- PPL-20260821-0001:示例家具(普通档)brand_multiplier=1.0
- PPL-20260821-0002:示例灯具(高端档)brand_multiplier=1.2
- PPL-20260821-0003:示例智能家居(超高端档)brand_multiplier=1.4
- 字段:ppl_id / business_key / brand_name_cn / brand_name_en / brand_tier / brand_multiplier / contact_person / contact_phone / contact_email / brn_reg_no / cooperation_start/end / status / label_version
- 新增
dip1/backend/app/infrastructure/db/seed/__init__.py + ppl_seed.py:
seed_ppl_producers(session) 函数:读 JSON → 幂等 INSERT(business_key 唯一,已存在跳过)
- 字段对齐 ORM
MdsPplProducer(id=字符串主键,business_key 唯一,brand_multiplier Numeric(3,2))
- 挂载:
dip1/backend/app/infrastructure/db/session.py init_db() SQLite 分支末尾自动跑
- 仅 SQLite 开发模式自动跑;生产由 OCM 经 MDM 审批流导入
- conftest fixture 不跑 lifespan,测试 DB 不受影响(PPL 表仍为空,走档位回落)
3. 测试运行结果
后端 pytest(Python 3.12.10 + pytest 9.1.1 + pytest-asyncio 1.4.0 + reportlab 5.0.0)
| 测试范围 |
用例数 |
通过 |
失败 |
时长 |
tests/api/test_qsv_quote.py(含 MFR + PDF) |
12 |
12 |
0 |
15.5s |
tests/api/test_dev_token.py(新增) |
3 |
3 |
0 |
<1s |
tests/api/test_mds_supply.py(PPL ORM 回归) |
22 |
22 |
0 |
21.4s |
| 全量(all tests/) |
272 |
272 |
0 |
105.2s |
- 关键测试新增:
test_mfr_open_quote 增加 PDF 端点断言(%PDF magic + content-type + content-disposition),覆盖 P0-3 集成
- 回归验证:PPL ORM 类名 bug 修复后 mds_supply 22 项全过,无回归
- 警告:HMAC key 长度 25 < 32 字节推荐长度(开发模式默认密钥,生产用 Keycloak RS256 不影响)
后端 ruff(0.16.3)
- 7 个改动文件全过:
app/interfaces/api/qsv_router.py / dev_router.py / app/application/qsv/pdf.py / adapters.py / app/infrastructure/db/seed/ / app/interfaces/api/__init__.py / session.py
前端 typecheck + build(pnpm + tsc + next 14.2.5)
| Workspace |
typecheck |
build |
| packages/shared |
✅ |
- |
| apps/cst-app |
✅ |
- |
| apps/lgp-portal |
✅ |
- |
| apps/mfr-portal(本次重点) |
✅ |
✅ 4 页面(/、/login、/quote、/register)静态生成,First Load 87~101 kB |
| apps/opr-admin |
✅ |
- |
| apps/wkr-app |
✅ |
- |
4. 最小部署资源需求
| 项 |
最小配置 |
说明 |
| CPU |
1 核 |
单进程 uvicorn + Next.js start |
| 内存 |
1 GB |
SQLite + 进程内事件总线 + 进程内缓存(30s TTL) |
| 磁盘 |
1 GB |
代码 + SQLite db + node_modules + .next |
| 服务 |
2 个 |
① uvicorn app.main:app --port 9100(后端) ② pnpm --filter @dip1/mfr-portal start(前端 :3001) |
| 数据库 |
SQLite 文件 |
./data/dip1.db(init_db 自动建表 + PPL 种子自动 INSERT) |
| 缓存 |
进程内 |
DIP1_REDIS_URL= 空 |
| 事件总线 |
进程内 |
DIP1_NATS_URL= 空 |
| 鉴权 |
开发模式 HS256 |
DIP1_KEYCLOAK_URL= 空 → is_dev_auth=True,挂载 dev_router |
| 反代(可选) |
Kong :8000 → :9100 |
单机部署可不加,前端 NEXT_PUBLIC_API_BASE_URL 直连后端 :9100 |
5. 部署步骤建议(给听云 OCM 参考)
⚠ 本节为部署建议,听码 WDE 不执行生产部署;听云 OCM 按实际生产环境调整。
5.1 镜像/代码包准备
- 听码(dip1/ 本地代码) → 听写打包代码包(hk2026 私有主仓备份 + scp/rsync 推送听云暂存区)
- 听云收到代码包后部署到阿里云
5.2 后端启动
# 进入 backend 目录
cd dip1/backend
# 安装依赖(含本次新增 reportlab>=4.0)
python -m pip install -e .
# 配置 .env(最小化零依赖模式)
cat > .env <<EOF
DIP1_DATABASE_URL=sqlite+aiosqlite:///./data/dip1.db
DIP1_REDIS_URL=
DIP1_NATS_URL=
DIP1_KEYCLOAK_URL=
DIP1_JWT_SECRET=<随机 32 字节以上字符串>
DIP1_DISABLED_MODULES=
EOF
# 启动(生产建议 gunicorn + uvicorn worker)
uvicorn app.main:app --host 0.0.0.0 --port 9100
# 启动时 lifespan 自动:① init_db() 建表 ② PPL 种子 INSERT(幂等)
5.3 前端启动
cd dip1/frontend
pnpm install --filter @dip1/mfr-portal...
# 配置 API base URL(生产域名 / 反代)
echo "NEXT_PUBLIC_API_BASE_URL=https://api.<your-domain>" > apps/mfr-portal/.env.local
pnpm --filter @dip1/mfr-portal build
pnpm --filter @dip1/mfr-portal start # 监听 :3001
5.4 验证冒烟
- 后端:
curl http://localhost:9100/health → {"status":"ok", ...}
- dev token:
curl -X POST http://localhost:9100/api/v2/dev/token -H 'Content-Type: application/json' -d '{"sub":"smoke-test"}'
- MFR 报价:用上面返回的 token + ppl_id Header 调 POST
/api/v2/qsv/quote/mfr-open
- PDF 下载:用返回的 quote_id 调 GET
/api/v2/qsv/quote/{quote_id}/pdf,应返回 application/pdf
- 前端:浏览器打开 http://localhost:3001 → 跳 /login → "签发并登录" → /quote → 填表单 → 提交 → 下载 PDF
5.5 生产切换(V2 演进,不在本次范围)
- 配置
DIP1_KEYCLOAK_URL → dev_router 自动摘除(生产由 Keycloak 替代)
- 切换
DIP1_DATABASE_URL=postgresql+asyncpg://... → 走 Alembic 迁移,PPL 种子由 OCM 经 MDM 审批流导入
- 配置
DIP1_REDIS_URL / DIP1_NATS_URL → 切多实例 + 事件总线
- MFR 个人注册:接 Keycloak 25 OIDC 真注册流(替换 V1 dev token 占位)
6. 遗留问题与风险
| # |
项 |
严重度 |
说明 / 建议处理 |
| 1 |
PDF 中文字体回退 |
低 |
reportlab 内置 STSong-Light(Adobe Asian 字体包,pip 安装即有);若个别精简镜像未带 CMap 数据,会回落 Helvetica(中文乱码但 PDF 仍生成)。建议生产镜像验证 python -c "from reportlab.pdfbase.cidfonts import UnicodeCIDFont; UnicodeCIDFont('STSong-Light')" |
| 2 |
dev token 端点生产暴露风险 |
中 |
已用 settings.is_dev_auth 双重保险:①未配置 Keycloak 才挂载 ②生产配置 DIP1_KEYCLOAK_URL 后自动摘除。OCM 部署前务必确认 DIP1_KEYCLOAK_URL 非空 |
| 3 |
register 页 PPL 下拉需登录 |
低 |
/api/v2/mds/supply/cpt/ppl-options 需 ANY_ROLE 鉴权,未登录用户拿不到列表 → 前端 datalist 兼容手填。V2 可加 /api/v2/mds/supply/public/ppl-options 公开端点(仅返回非敏感字段),但不在 V1 最小化范围 |
| 4 |
MFR 个人注册 V1 是占位 |
中 |
V1 用 dev token 签发 MFR-USR 作为"注册成功"占位,无密码哈希/用户表落库。V2 必须接 Keycloak 25 OIDC 真注册流;生产部署严禁用 V1 流程对外提供注册 |
| 5 |
quote 页 P5 GPS 占位坐标 |
低 |
V1 安装地址仅记录 addr_text,GPS 用香港中心占位 22.3193,114.1694。V2 接地图 API 选址 → 真实 GPS |
| 6 |
P2.quantity 不参与计价 |
低 |
V1 数量仅前端展示,单价已含工时;V2 多件同品类应支持 P1.estimated_hours × qty 或批量派单(DIS MFR-batch) |
| 7 |
HMAC key 长度警告 |
低 |
开发模式默认 dip1-dev-secret-change-me 25 字节 < 32 推荐;生产改 DIP1_JWT_SECRET ≥ 32 字节即消除警告 |
| 8 |
CORS 未配置 |
中 |
前端 :3001 → 后端 :9100 跨端口,生产部署需在 backend 挂 CORSMiddleware(允许 mfr-portal 域名);当前测试用 ASGI transport 不触发 CORS。建议听云部署时加 app.add_middleware(CORSMiddleware, allow_origins=[...], ...) |
7. 不在本次补全范围(V2 演进项)
按"最小化原则"以下项延后,与 L-20260823-01 §8 一致:
- CPT 线索转化(MFR 报价 → CPT 项目)
- 30 天过期机制(前端硬编码 +30 天展示,后端未做时效校验)
- 品牌徽章展示
- MFR 批量派单(DIS MFR-batch)
- Keycloak OIDC 真注册流
- Keycloak RS256 验签(开发期用 HS256)
- 报价历史/版本对比 UI(后端 list_snapshots 已实现,前端未做)
- 真实 GPS 地图选址
8. 修订记录
| 版本 |
修订内容 |
| V1.0 |
2026-08-23 听码 WDE 初稿:完成 10 项补全执行 + 测试通过 + 部署建议 + 遗留问题 |
本日志登记 MFR 开放报价功能补全执行结果;前序核查见 L-20260823-01。