跳转至

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-openPOST /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。