跳转至

L-20260823-01 MFR 开放报价功能完成度核查

任务编号:L-20260823-01 / 执行人:听码 WDE / 日期:2026-08-23 任务来源:用户指令"最低水平服务器资源尽快上线 MFR 端开放报价功能";听写 DTL L-20260823-02 协调 核查范围dip1/ 本地代码(后端 QSV + 前端 mfr-portal + 部署配置) 约束:第一步只读核查不修改;不执行 git push/pull;最小化原则


1. 任务起止与输入产物

  • 开始/完成:2026-08-23
  • 输入产物
  • RQD-O MFR-11 / QTE-09 / QMD §MFR 开放报价参数精简版(最小化 V1 范围)
  • 听写 DTL L-20260823-02 协调记录
  • 已知代码位置标注:backend/app/application/qsv/services.py L235-238 mfr_open_quote()frontend/apps/mfr-portal/app/page.tsx L1-14 骨架
  • 输出产物:本核查报告 + 完成度表格 + 补全清单(见 §4)

2. 后端核查结果(QSV 报价服务)

2.1 mfr_open_quote() 服务方法(services.py L235-238)

async def mfr_open_quote(self, dto: QuoteParams32In, *, ppl_id: str) -> QuoteResultOut:
    """MFR 门户开放报价(D05 §二 MFR-05):PPL 品牌溢价由 MDS PPL 表自动识别写入 P8。"""
    dto = dto.model_copy(update={"P8": dto.P8.model_copy(update={"brand_multiplier": None})})
    return await self.create_quote(dto, ppl_id=ppl_id)
  • ✅ P8.brand_multiplier=None 强制覆盖:使 _build_paramsMdsParamProviderAdapter.get_brand_multiplier(ppl_id, ppl_brand_tier) 回落链
  • ✅ 复用 create_quote:v1 快照落库 + 发布 qsv.quote.created 事件
  • ✅ ppl_id 由 Header 注入,不写死在 body

2.2 API 路由(backend/app/interfaces/api/qsv_router.py L106-115)

@router.post("/api/v2/qsv/quote/{quote_id}/mfr-open", status_code=201, response_model=QuoteResultOut)
async def mfr_open_quote(
    quote_id: str,                  # ⚠ 声明但未使用
    body: QuoteParams32In,
    svc: QuoteAppService = Depends(_svc),
    principal: Principal = Depends(require_roles("MFR-ADM", "MFR-USR", "OL", "BO")),
    ppl_id: str = Header(alias="ppl_id"),
) -> QuoteResultOut:
    return await svc.mfr_open_quote(body, ppl_id=ppl_id)
  • 📋 路由路径与前端契约不一致
  • 后端实际:POST /api/v2/qsv/quote/{quote_id}/mfr-open(路径参数 quote_id 声明但函数体从未使用)
  • 前端期望:POST /api/v2/qsv/quote/mfr-open(无 quote_id,见 mfr-portal/app/page.tsx L4 注释、opr-admin/app/(console)/qsv/page.tsx L15 注释)
  • 测试 test_qsv_quote.py::test_mfr_open_quote L163 用 "QTE-any" 占位值绕过,测试通过但路径语义错误
  • 业务语义:MFR 开放报价是"新建"动作,quote_id 应是输出而非输入;当前路径设计冗余

2.3 请求/响应 schema(backend/app/application/qsv/dtos.py

  • QuoteParams32In 完整:P0~P8 共 9 个分组(P2 弹性 dict)+ ConfigDict(extra="ignore")
  • P0In.project_id 正则 ^(CPT-|MFRQ-)\d{8}-\d{4}$ —— 已为 MFR 开放报价预留 MFRQ- 前缀
  • P8In.ppl_brand_tier Literal["STANDARD","PREMIUM","SUPER_PREMIUM"] —— 与 PPL 表 brand_tier 字段语义对齐
  • QuoteResultOut 含 subtotal_steps / dme_selected / engine_version / calculation_at

2.4 P8.brand_multiplier=None 回落链(adapters.py L179-187)

async def get_brand_multiplier(self, ppl_id, ppl_brand_tier) -> float | None:
    if ppl_id:
        mult = await self._query_mds(f"brand_multiplier:{ppl_id}", "MdsPplBrand", "brand_multiplier", "ppl_id", ppl_id)
        if mult is not None and float(mult) in BRAND_MULTIPLIER_BY_TIER.values():
            return float(mult)
    return BRAND_MULTIPLIER_BY_TIER.get(ppl_brand_tier, 1.0)
  • ✅ 查询顺序:进程内缓存 30s → MDS mds_ppl_producer 表 → 档位枚举回落(1.0/1.2/1.4)
  • ✅ 容错:MDS 表不存在/查询异常 → 回落 BRAND_MULTIPLIER_BY_TIER 默认值
  • MDS 表 ORM 模型不匹配:adapters.py 引用 MdsPplBrand,但实际模型类名是 MdsPplProducer(infrastructure/db/models/mds.py L183)—— _query_mdsgetattr(mds_models, model_attr, None) 取不存在的类名会返回 None,导致永远走档位回落而非 PPL 表实际值
  • 影响:MFR 门户即使填写正确的 ppl_id,也读不到 PPL 表中的真实 brand_multiplier,统一回落到 ppl_brand_tier 档位
  • 严重度:MFR 报价功能仍可用(档位回落兜底),但 PPL 表数据失效

2.5 MDS PPL 表查询与 ORM 模型

  • MdsPplProducer 表 schema 完整(infrastructure/db/models/mds.py L183-202):business_key / brand_name_cn / brand_tier / brand_multiplier Numeric(3,2) / status / label_version 等
  • ✅ Alembic migration b9206c2383d5_0001_initial_5svc.py L269-291 含 op.create_table('mds_ppl_producer') + 索引 + 唯一约束
  • MdsPplProduct 子表(产品 → SCL 类目映射)也已在 migration L292-303 建表

2.6 PDF 生成逻辑

  • 完全未实现Select-String -Pattern "pdf|PDF|Pdf" 在整个 dip1/backend/ 零命中
  • 当前 QuoteResultOut 仅返回 JSON,无 PDF 端点、无 PDF 渲染依赖(pyproject.toml 仅有 fastapi/sqlalchemy/pydantic/jwt/httpx/aiosqlite/asyncpg/nats-py/redis,无 reportlab/weasyprint/pdfkit)
  • 影响:任务要求"PDF 下载"是 V1 最小化范围之一,必须补全

2.7 QSV 报价引擎独立性

  • PricingEngine + DefaultJsonRuleEngine 领域层纯 Python,不依赖 CPT/DIS/OPS
  • MdsParamProviderAdapter 容错查询:MDS 表不存在/查询异常 → 回落设计文档默认参数("MDS默认参数回落"),保证 QSV 可独立启
  • app/main.py lifespan:SQLite 模式自动 init_db() 建表,无需 alembic upgrade

3. 前端核查结果(frontend/apps/mfr-portal/

3.1 完整目录结构

mfr-portal/
├── app/
│   ├── layout.tsx       (438B,RootLayout + zh-HK lang)
│   ├── page.tsx         (796B,骨架占位"开发中"+ 登录链接)
│   └── login/page.tsx   (488B,骨架占位"开发中")
├── package.json         (next 14.2.5 + react 18.3.1 + @dip1/shared workspace:*)
├── next.config.mjs      (transpilePackages: @dip1/shared)
├── tsconfig.json        (extends ../../tsconfig.base.json, paths @/*)
├── next-env.d.ts
└── tsconfig.tsbuildinfo (构建缓存)
  • lib/ 目录:未引入 @dip1/shared 的 Dip1ApiClient,未配置 NEXT_PUBLIC_API_BASE_URL
  • components/ 目录:无表单组件、无结果展示组件、无 PDF 下载按钮
  • app/register/page.tsx:任务要求"个人注册"未实现
  • app/quote/page.tsx:任务要求"精简输入表单 + 自动报价 + 结果展示 + PDF 下载"未实现

3.2 现有页面完成度

页面 当前状态 与 V1 要求差距
app/page.tsx 骨架占位("开发中"标题 + 登录链接) ❌ 无注册入口、无报价引导、无 PPL 品牌选择
app/login/page.tsx 骨架占位("开发中"文字说明) ❌ 无 dev JWT 粘贴框(对比 opr-admin/app/login/page.tsx 已实现),无法登录
app/layout.tsx RootLayout 完成 ✅ 可用

3.3 与 opr-admin 可复用资产对比

  • @dip1/shared 包已含 Dip1ApiClient(fetch 封装 + JWT bearer + ApiError)、types/qsv.ts(QuoteParams32 / QuoteResult)、types/mds.ts(PplBrand)
  • ✅ opr-admin lib/api.ts / lib/auth.ts 模板可直接复用(仅改 baseURL env 名)
  • ✅ opr-admin app/globals.css 品牌色变量(#004046 深青 / #D4AF78 金)可直接搬

4. 数据库与部署核查

4.1 PPL 表 schema

  • ✅ ORM MdsPplProducer 字段完整(见 §2.5)
  • ✅ Alembic migration 已含建表语句
  • ✅ SQLite 模式 init_db() 自动建表(main.py lifespan)
  • 无种子数据backend/data/ 仅含 .gitkeep,无 seed 脚本/SQL;PPL 表建后为空
  • 影响:MFR 门户品牌下拉无选项;PPL 表查询永远命中空 → 走档位回落
  • 同时发现 §2.4 的 ORM 类名 bug,即使有种子数据也读不到

4.2 部署配置(deploy/docker-compose.yml

  • 现状:8 服务全栈(postgres+postgis / redis / nats / keycloak / kong / mailpit / jaeger / backend,端口 5432/6379/4222/8080/8000/1025/4317/9100)
  • 最小化部署可行:默认 .env.example 全部走零依赖模式
  • DIP1_DATABASE_URL=sqlite+aiosqlite:///./data/dip1.db
  • DIP1_REDIS_URL=(空 = 进程内缓存)
  • DIP1_NATS_URL=(空 = 进程内事件总线)
  • DIP1_KEYCLOAK_URL=(空 = 开发模式 HS256 验签)
  • 最小部署命令:uvicorn app.main:app --port 9100 + pnpm --filter @dip1/mfr-portal start,单机 1C1G 可启
  • make_dev_token() 函数已实现(security.py L88-93)但未暴露 HTTP 端点,前端无法自助获取 dev JWT;opr-admin 登录页用文本框粘贴 token,但用户无渠道生成

5. 完成度汇总表

组件 状态 说明
后端 mfr_open_quote() 服务方法 ✅ 完整实现 P8.brand_multiplier=None + 复用 create_quote
后端 API 路由 📋 有缺陷 路径含无意义 {quote_id} 参数,与前端契约不一致(前端期望无 quote_id)
后端 请求/响应 schema ✅ 完整 QuoteParams32In + QuoteResultOut,MFRQ- 前缀已预留
后端 P8.brand_multiplier 回落链 📋 有缺陷 adapters.py 引用 MdsPplBrand,但 ORM 类名是 MdsPplProducer,PPL 表查询永远失败回落档位
后端 MDS PPL 表 ORM + migration ✅ 完整 MdsPplProducer + MdsPplProduct + alembic 建表语句
后端 PDF 生成 ❌ 未实现 零代码、零依赖,必须从 0 补
后端 QSV 引擎独立性 ✅ 完整 可独立运行,MDS 缺失时回落默认参数
后端 dev token 生成端点 📋 有缺陷 make_dev_token 函数存在但未暴露 HTTP,前端无法自助登录
前端 mfr-portal app/layout.tsx ✅ 完整 RootLayout 可用
前端 mfr-portal app/page.tsx ❌ 骨架占位 仅"开发中"标题,无注册/表单/结果/PDF
前端 mfr-portal app/login/page.tsx ❌ 骨架占位 仅文字说明,无 JWT 粘贴框(对比 opr-admin 已实现)
前端 mfr-portal 注册流程 ❌ 未实现 app/register/page.tsx
前端 mfr-portal 精简输入表单 ❌ 未实现 app/quote/page.tsx 或类似
前端 mfr-portal 报价结果展示 ❌ 未实现 无组件
前端 mfr-portal PDF 下载 ❌ 未实现 无组件、无 API 调用
前端 mfr-portal lib/(api+auth) ❌ 未实现 未引入 @dip1/shared Dip1ApiClient
前端 mfr-portal components/ ❌ 未实现 无表单/结果/PDF 组件
前端 mfr-portal globals.css ❌ 未实现 无品牌样式(可从 opr-admin 搬)
数据库 PPL 种子数据 ❌ 未实现 backend/data 仅 .gitkeep
部署配置 docker-compose ✅ 完整 8 服务全栈 + .env.example 零依赖默认
最小资源部署可行性 ✅ 可行 SQLite+进程内总线+HS256 JWT,单机 1C1G 可启

6. 补全清单(最小化部署所必需)

按"最低水平服务器资源尽快上线"原则,以下为最小化部署必需的补全项,按优先级排序:

P0 阻塞项(不补无法上线)

  1. 后端:修复 MFR 路由路径
  2. 文件:backend/app/interfaces/api/qsv_router.py L106-115
  3. 改动:@router.post("/api/v2/qsv/quote/{quote_id}/mfr-open")@router.post("/api/v2/qsv/quote/mfr-open"),删除 quote_id: str 参数
  4. 同步:更新 tests/api/test_qsv_quote.py::test_mfr_open_quote L163 路径为 /api/v2/qsv/quote/mfr-open

  5. 后端:修复 PPL ORM 类名不匹配

  6. 文件:backend/app/application/qsv/adapters.py L182
  7. 改动:"MdsPplBrand""MdsPplProducer"(对齐 infrastructure/db/models/mds.py L183 实际类名)

  8. 后端:补 PDF 生成端点

  9. 新增:backend/app/interfaces/api/qsv_pdf.py 或在 qsv_router 增加 GET /api/v2/qsv/quote/{quote_id}/pdf 路由
  10. 依赖:pyproject.toml 增加 reportlab>=4.0(轻量纯 Python,无系统级依赖,符合最小资源原则)
  11. 实现:用 QuoteResultOut + QuoteSnapshotOut 字段渲染简单表格 PDF(报价单号 / 客户 / 品类 / 工时 / 各步金额 / 最终金额 / 计算时间)
  12. 不引入 weasyprint(需系统级 GTK)、pdfkit(需 wkhtmltopdf)—— 违反最小资源原则

  13. 后端:补 dev token 生成端点(开发期)

  14. 新增:backend/app/interfaces/api/dev_router.py,路由 POST /api/v2/dev/token(仅 settings.is_dev_auth 时挂载)
  15. 入参:{subject: str, roles: list[str]},出参:{token: str}
  16. 用途:mfr-portal 登录页用此端点获取 dev JWT(生产由 Keycloak 替代)

P1 必需项(不补无法演示)

  1. 前端:新建 mfr-portal lib/ 目录
  2. lib/api.ts:复用 opr-admin 同名文件模板,baseURL 改 NEXT_PUBLIC_API_BASE_URL 默认 http://localhost:9100
  3. lib/auth.ts:复用 opr-admin 同名文件(localStorage key 沿用 dip1.jwt

  4. 前端:补 mfr-portal app/globals.css

  5. 直接复制 apps/opr-admin/app/globals.css(品牌色 + 通用样式类)

  6. 前端:补 mfr-portal app/login/page.tsx

  7. 复用 opr-admin app/login/page.tsx 模板,调整文案为"MFR 生产商门户 · 登录"
  8. 增加"无 dev token?点此生成"链接 → 调用 /api/v2/dev/token 端点(dev 模式)

  9. 前端:补 mfr-portal app/register/page.tsx

  10. 个人注册表单:手机号 / 昵称 / 密码(V1 最小化,不接 Keycloak 注册流)
  11. 调用:可暂用 dev token 端点签发 MFR-USR 角色作为"注册成功"占位(V2 接 Keycloak 真注册流)
  12. 任务要求"个人注册"+"可选绑定 MFR":绑定 MFR 为可选字段(ppl_id),不强制

  13. 前端:补 mfr-portal app/quote/page.tsx(核心)

  14. 精简输入表单:品类(SCL 8 选 1 下拉)/ 工时(estimated_hours 数值)/ 楼宇类型(P3 默认 1.0)/ 区域(P4 4 选 1)/ 师傅等级(P8 默认 AUTH)/ 品牌档位(P8 默认 STANDARD,可选)/ P0.project_id 自动生成 MFRQ-yyyyMMdd-NNNN
  15. 提交:POST /api/v2/qsv/quote/mfr-open,Header ppl_id(可选,登录态注入)
  16. 结果展示:final_amount HKD + subtotal_steps 明细表 + dme_selected + engine_version + calculation_at
  17. PDF 下载按钮:GET /api/v2/qsv/quote/{quote_id}/pdf → 浏览器下载 quote_{quote_id}.pdf

P2 数据项(不补 PPL 表查询永远走档位回落)

  1. 后端:补 PPL 种子数据
    • 新增:backend/app/infrastructure/db/seed/ppl_seed.py(或类似)
    • 内容:至少 3 个示例 PPL(普通 1.0 / 高端 1.2 / 超高端 1.4 各 1 个)
    • 触发:init_db() 后自动 INSERT(SQLite 模式);生产由 OCM 经 MDM 审批流导入

7. 阻塞与决策点

  • 阻塞 1:后端路由路径与前端契约不一致(§2.2)—— 已确定修复方案(删除 quote_id 参数),需同步更新测试
  • 阻塞 2:PPL ORM 类名不匹配(§2.4)—— 已确定修复方案(MdsPplBrand → MdsPplProducer),可能影响 test_mds_supply.py 等其他测试,需回归
  • 决策点 1:PDF 生成方案选 reportlab(纯 Python)还是 weasyprint(需系统依赖)—— 已选 reportlab,符合"最小资源"原则
  • 决策点 2:dev token 端点是否在生产模式暴露 —— 不暴露,仅 settings.is_dev_auth=True 时挂载,生产由 Keycloak 替代
  • 决策点 3:MFR 个人注册 V1 是否接 Keycloak —— 不接,V1 用 dev token 端点签发 MFR-USR 角色占位,V2 再接 Keycloak 真注册流

8. 不在本次核查范围

按任务约束"最小化原则",以下 V2 范围不在补全清单: - CPT 线索转化(MFR 报价 → CPT 项目) - 30 天过期机制 - 品牌徽章展示 - MFR 批量派单(DIS MFR-batch,已在 dis_router 但不在 V1) - Keycloak OIDC 真注册流 - Keycloak RS256 验签(开发期用 HS256)


9. 修订记录

版本 修订内容
V1.0 2026-08-23 听码 WDE 初稿:完成核查与补全清单,未修改任何代码(第一步只读)

本日志登记 MFR 开放报价功能完成度核查结果;后续补全工作另起 WDL 编号登记。