openapi: 3.0.3
info:
  title: WEAVELY DIP1 - 香港配送+安装平台 API
  description: |
    对齐 DIP1-SPEC V2.0 需求规格（213 功能点）。
    Base URL 规则：
      - 本地开发：http://localhost:8000
      - 开发环境：https://dev-hk.weavely.hk/api
      - 生产环境：https://hk.weavely.hk/api
    版本前缀 /v1；鉴权：Authorization: Bearer <JWT>，过期需调用 /auth/refresh。
    Idempotency：写接口支持 Idempotency-Key: <uuid>，重复请求返回原响应（HTTP 422 code=2002 若过期）。
    通用错误码：见 DIP1-SPEC 附录 A（1001 未登录 / 1002 403 / 1003 Refresh 无效 / 1004 OTP 失败 / 1005 生物识别失败 / 3001 业务冲突 / 3010 QSV 校验 / 3020 CPT 校验 / 3040 推送失败 / 3050 询价校验 / 5001 内部）

    **V2.0 变更**：① 新增 08 客户端服务 CST（13 path / 15 operationId，对应 cst-app React Native）；② 新增 09 移动认证 & 推送（5 path：OTP 发送/验证 + 生物识别 + Push Token 注册 + 推送发送）；③ 飞书同步相关端点（adminFeishuSyncStatus / adminFeishuFullResync / DLQ 重放）标注 ⏸ V2.0 暂不实施；④ operationId 总数 52 → 72（+20：CST 15 + 移动认证/推送 5）；⑤ 对齐 ADR-002/006/007/008。
  version: "2.0.0"
  contact:
    name: 织布鸟 TL（司徒总）
    email: tl@weavely.hk
  license:
    name: 内部文档 · 商业机密 · 禁止外泄
servers:
  - url: http://localhost:8000/api/v1
    description: 本地开发
  - url: https://dev-hk.weavely.hk/api/v1
    description: 香港预发环境
  - url: https://hk.weavely.hk/api/v1
    description: 香港生产
tags:
  - name: 00 健康检查
    description: /health 无需鉴权
  - name: 01 认证
    description: login / refresh / me / logout / otp-send / otp-verify / biometric（V2.0 新增 OTP + 生物识别）
  - name: 02 报价服务 QSV
    description: QMD 8 因子计算 + 报价历史 + 参数版本
  - name: 03 订单跟踪服务 CPT
    description: 8 阶段 L1-L8 生命周期 + 6 跟踪池看板 + L6 6S
  - name: 04 主数据服务 MDS
    description: 四库（CPL/WPL/SCL/BPL）+ TSMM 标签体系
  - name: 05 运营服务 OPS
    description: 6 场景线索池 + 推荐关系 + 口碑素材 + 奖励发放
  - name: 06 调度服务 DSP
    description: 派单推荐 + 师傅负载
  - name: 07 系统管理
    description: RBAC / RLS / 审计 / DLQ / 飞书同步（飞书 + DLQ V2.0 ⏸ 暂不实施）
  - name: 08 客户端服务 CST
    description: V2.0 新增 · 客户端 App（cst-app）专用 · 询价 / 下单 / 订单跟踪 / 评价 / 推荐有礼
  - name: 09 移动认证 & 推送
    description: V2.0 新增 · OTP 登录 / 生物识别 / Expo Push Token 注册 / 推送发送（WKR + CST 双端）

security:
  - BearerAuth: []

paths:

  # ========== 00 健康检查 ==========
  /health:
    get:
      tags: [00 健康检查]
      summary: 健康检查（探针用，无需鉴权）
      operationId: getHealth
      security: []
      responses:
        "200":
          description: 服务正常
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthCheck"

  # ========== 01 认证 ==========
  /auth/login:
    post:
      tags: [01 认证]
      summary: 用户名/密码登录（运营后台 + 师傅端统一入口）
      operationId: authLogin
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginRequest" }
      responses:
        "200": { description: 登录成功, content: { application/json: { schema: { $ref: "#/components/schemas/LoginResponse" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/refresh:
    post:
      tags: [01 认证]
      summary: 用 refresh_token 换 access_token
      operationId: authRefresh
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string, example: eyJhbGciOiJIUzI1NiJ9... }
      responses:
        "200": { description: 刷新成功, content: { application/json: { schema: { $ref: "#/components/schemas/LoginResponse" } } } }

  /auth/me:
    get:
      tags: [01 认证]
      summary: 获取当前登录用户（含角色 + 权限 + 脱敏联系）
      operationId: authMe
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/CurrentUser" } } } }

  # ========== 02 报价服务 QSV ==========
  /qsv/calculate:
    post:
      tags: [02 报价服务 QSV]
      summary: 实时报价预览（不保存，前端 300ms 防抖调用）
      operationId: qsvCalculate
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QsvCalculateRequest" }
      responses:
        "200":
          description: 报价预览
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QsvCalculateResponse" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /qsv/quotes:
    get:
      tags: [02 报价服务 QSV]
      summary: 报价历史列表（按订单/客户/时间段/状态筛选）
      operationId: qsvListQuotes
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - { name: order_id, in: query, schema: { type: string }, example: ORD-01HQWYX1B888XZ29KM63P5G6VV }
        - { name: customer_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [DRAFT, CONFIRMED, REJECTED, EXPIRED] } }
        - { name: created_start, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: 分页报价历史
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QsvQuotePage" }
    post:
      tags: [02 报价服务 QSV]
      summary: 创建报价（DRAFT），若有订单关联则同步 CPT
      operationId: qsvCreateQuote
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QsvCreateQuoteRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QsvQuote" }
        "409": { $ref: "#/components/responses/Conflict" }

  /qsv/quotes/{quote_id}:
    parameters:
      - $ref: "#/components/parameters/quote_id"
    get:
      tags: [02 报价服务 QSV]
      summary: 报价详情（含 8 因子展开快照，确保历史不改）
      operationId: qsvGetQuote
      responses:
        "200":
          description: 详情
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QsvQuoteDetail" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [02 报价服务 QSV]
      summary: 状态流转：CONFIRMED / REJECTED（DRAFT 才允许改）
      operationId: qsvUpdateQuoteStatus
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [CONFIRMED, REJECTED] }
                bo_approval_note: { type: string }
      responses:
        "200": { description: OK }

  /qsv/quotes/{quote_id}/export:
    parameters: [ { $ref: "#/components/parameters/quote_id" } ]
    get:
      tags: [02 报价服务 QSV]
      summary: 导出报价单 PDF（A4 + 30 天有效期）
      operationId: qsvExportPdf
      responses:
        "200":
          description: PDF 二进制流
          content:
            application/pdf:
              schema: { type: string, format: binary }

  /qsv/params/versions:
    get:
      tags: [02 报价服务 QSV]
      summary: 报价参数版本列表（TL/BO 可见）
      operationId: qsvListParamVersions
      parameters: [ { $ref: "#/components/parameters/page" }, { $ref: "#/components/parameters/page_size" } ]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/QsvParamVersion" }

  /qsv/calibration-report:
    post:
      tags: [02 报价服务 QSV]
      summary: 生成 QMD 校准报告（按品类 actual_hours vs baseline）
      operationId: qsvGenerateCalibrationReport
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                start_date: { type: string, format: date }
                end_date: { type: string, format: date }
      responses:
        "200":
          description: 报告 + 建议列表
          content:
            application/json:
              schema:
                type: object
                properties:
                  report_id: { type: string }
                  summary:
                    type: array
                    items:
                      type: object
                      properties:
                        category_id: { type: string }
                        actual_avg_hours: { type: number, format: float }
                        baseline_hours: { type: number, format: float }
                        suggestion: { type: string, enum: [RAISE_BASE_FEE_10PCT, RAISE_BASE_FEE_20PCT, NO_ACTION, LOWER_CAP] }

  # ========== 03 订单跟踪服务 CPT ==========
  /cpt/orders/board:
    get:
      tags: [03 订单跟踪服务 CPT]
      summary: 6 跟踪池看板（每池卡片 20 条分页，FL 角色只看自己）
      operationId: cptListBoardPools
      parameters:
        - name: pools
          in: query
          schema:
            type: array
            items:
              type: string
              enum: [POOL_L1_NEW_LEADS, POOL_L3_PENDING_QUOTE, POOL_L4_PENDING_ASSIGN, POOL_L6_INSTALL_TODAY, POOL_L8_PENDING_FOLLOWUP, POOL_EXCEPTION]
        - { name: follow_up_id, in: query, schema: { type: string }, description: 只看某 OL 跟进（RLS 之外可选过滤）}
      responses:
        "200":
          description: 6 池卡片列表
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: array
                  items: { $ref: "#/components/schemas/CptOrderCard" }

  /cpt/orders:
    get:
      tags: [03 订单跟踪服务 CPT]
      summary: 全量订单列表（搜索 + 筛选）
      operationId: cptListOrders
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - { name: stage, in: query, schema: { type: string, enum: [L1, L2, L3, L4, L5, L6, L7, L8] } }
        - { name: status, in: query, schema: { type: string, enum: [IN_PROGRESS, COMPLETED, CANCELLED, EXCEPTION_PAUSED] } }
        - { name: category_id, in: query, schema: { type: string } }
        - { name: building_type, in: query, schema: { type: string } }
        - { name: assigned_worker_id, in: query, schema: { type: string } }
        - { name: q, in: query, schema: { type: string }, description: 客户姓名/电话/WhatsApp 搜索 }
      responses:
        "200":
          description: 分页
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/CptOrder" }
    post:
      tags: [03 订单跟踪服务 CPT]
      summary: 新建 L1 线索订单（OL/ML）
      operationId: cptCreateOrder
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CptCreateOrderRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CptOrder" }

  /cpt/orders/{order_id}:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    get:
      tags: [03 订单跟踪服务 CPT]
      summary: 订单详情（含 8 阶段 + 审计 + 附件全览）
      operationId: cptGetOrder
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CptOrderDetail" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [03 订单跟踪服务 CPT]
      summary: 修改订单基础字段（L2 需求部分）
      operationId: cptUpdateOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                customer_name: { type: string }
                product_model: { type: string }
                building_id: { type: string }
                address_detail: { type: string }
                site_conditions: { type: object, additionalProperties: true }
                notes: { type: string }
      responses:
        "200": { description: OK }

  /cpt/orders/{order_id}/transition:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    post:
      tags: [03 订单跟踪服务 CPT]
      summary: 阶段流转（L1→L2→L3→L4→L5→L6→L7→L8→DONE；TL 可强制跳阶段）
      operationId: cptTransitionStage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to_stage]
              properties:
                to_stage: { type: string, enum: [L2, L3, L4, L5, L6, L7, L8, DONE, EXCEPTION] }
                force: { type: boolean, default: false, description: TL 权限才可 true }
                reason: { type: string, description: 异常或强制跳转的原因 }
                payload: { type: object, description: 阶段流转所需数据：L6→L7 6S+照片+工时；L7→L8 签字照+结果；L8→DONE NPS }
      responses:
        "200":
          description: 流转成功，返回新阶段时间线
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CptOrderStage" }
        "422":
          description: 业务规则校验失败（缺字段/顺序非法）
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    field: { type: string }
                    message: { type: string }

  /cpt/orders/{order_id}/stages/{stage}/data:
    parameters:
      - { $ref: "#/components/parameters/order_id" }
      - { name: stage, in: path, required: true, schema: { type: string, enum: [L1, L2, L3, L4, L5, L6, L7, L8] } }
    put:
      tags: [03 订单跟踪服务 CPT]
      summary: 更新某阶段详细数据（L6 6S/照片/工时；L7 签字照/验收；L8 NPS）
      operationId: cptPutStageData
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                l6_data: { $ref: "#/components/schemas/CptL6Data" }
                l7_data: { $ref: "#/components/schemas/CptL7Data" }
                l8_data: { $ref: "#/components/schemas/CptL8Data" }
      responses:
        "200": { description: OK }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /cpt/orders/{order_id}/attachments:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    post:
      tags: [03 订单跟踪服务 CPT]
      summary: 上传附件（多图/视频，R2 存储，返回 signed URL）
      operationId: cptUploadAttachment
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  items:
                    type: string
                    format: binary
                category:
                  type: string
                  enum: [ENTRY, INSTALL_PROCESS, AFTER, SIGNATURE, CONTRACT, DELIVERY_DAMAGE, OTHER]
                stage:
                  type: string
                  enum: [L2, L3, L5, L6, L7, L8, EXCEPTION]
      responses:
        "200":
          description: 已上传元数据列表
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    attachment_id: { type: string }
                    url: { type: string }
                    content_type: { type: string }
                    size_bytes: { type: integer }

  # ========== 04 主数据服务 MDS ==========
  /mds/customers:
    get:
      tags: [04 主数据服务 MDS]
      summary: 客户档案分页（支持标签筛选）
      operationId: mdsListCustomers
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - { name: tag_ids, in: query, schema: { type: array, items: { type: string } } }
        - { name: q, in: query, schema: { type: string }, description: 姓名/WhatsApp/电话 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/CplCustomer" }
    post:
      tags: [04 主数据服务 MDS]
      operationId: mdsCreateCustomer
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CplCustomerUpsert" }
      responses:
        "201": { description: Created }

  /mds/customers/{customer_id}:
    parameters: [ { name: customer_id, in: path, required: true, schema: { type: string, example: CUS-01HQWYX2MNS2... } } ]
    get:
      tags: [04 主数据服务 MDS]
      operationId: mdsGetCustomer
      responses:
        "200":
          description: 客户画像详情（含五维标签 + 推荐关系链 + 历史订单）
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CplCustomer"
                  - type: object
                    properties:
                      history_orders_count: { type: integer }
                      lifetime_value_hkd: { type: number }
                      referral_from: { type: string, nullable: true }
                      referral_to: { type: array, items: { type: string } }
                      tags:
                        type: array
                        items:
                          type: object
                          properties:
                            tag_id: { type: string }
                            name: { type: string }
                            tier: { type: string, enum: [BASIC, ABILITY, CONTEXT] }
                            pdpo_level: { type: string, enum: [PUBLIC, INTERNAL, SENSITIVE] }

  /mds/workers:
    get:
      tags: [04 主数据服务 MDS]
      operationId: mdsListWorkers
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - { name: level, in: query, schema: { type: string, enum: [L1, L2, L3] } }
        - { name: status, in: query, schema: { type: string, enum: [AVAILABLE, BUSY, ON_LEAVE, SUSPENDED] } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/WplWorker" }
    post:
      tags: [04 主数据服务 MDS]
      operationId: mdsCreateWorker
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WplWorkerUpsert" }
      responses:
        "201": { description: Created }

  /mds/workers/{worker_id}:
    parameters: [ { name: worker_id, in: path, required: true, schema: { type: string } } ]
    get:
      tags: [04 主数据服务 MDS]
      operationId: mdsGetWorker
      responses:
        "200":
          description: 师傅详情（技能矩阵 8 品类 × 5 级 + 培训档案 + 投诉）
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WplWorker"
                  - type: object
                    properties:
                      skill_matrix:
                        type: object
                        additionalProperties:
                          type: integer
                          minimum: 0
                          maximum: 5
                      load_monthly_hours: { type: number, format: float }
                      avg_nps: { type: number, format: float }
                      total_orders: { type: integer }

  /mds/categories:
    get:
      tags: [04 主数据服务 MDS]
      operationId: mdsListCategories
      parameters: [ { $ref: "#/components/parameters/page" }, { $ref: "#/components/parameters/page_size" } ]
      responses:
        "200":
          description: SCL 品类 8 大类 + 子类目表
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/SclCategory" }
    post:
      tags: [04 主数据服务 MDS]
      operationId: mdsCreateCategory
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SclCategory" }
      responses:
        "201": { description: Created }

  /mds/buildings:
    get:
      tags: [04 主数据服务 MDS]
      operationId: mdsListBuildings
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - { name: q, in: query, schema: { type: string }, description: 地址联想 autocomplete }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/BplBuilding" }
    post:
      tags: [04 主数据服务 MDS]
      operationId: mdsCreateBuilding
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BplBuildingUpsert" }
      responses:
        "201": { description: Created }

  /mds/tags:
    get:
      tags: [04 主数据服务 MDS]
      summary: TSMM 标签定义
      operationId: mdsListTags
      parameters:
        - { name: entity, in: query, schema: { type: string, enum: [WORKER, CUSTOMER, BUILDING, CATEGORY] } }
      responses:
        "200":
          description: 列表
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    tag_id: { type: string }
                    name: { type: string }
                    entity: { type: string, enum: [WORKER, CUSTOMER, BUILDING, CATEGORY] }
                    tier: { type: string, enum: [BASIC, ABILITY, CONTEXT] }
                    pdpo_level: { type: string, enum: [PUBLIC, INTERNAL, SENSITIVE] }

  # ========== 05 运营服务 OPS ==========
  /ops/leads:
    get:
      tags: [05 运营服务 OPS]
      summary: 线索池（6 场景 Tab）
      operationId: opsListLeads
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - { name: scene, in: query, schema: { type: string, enum: [SCENE01_ONLINE_AD, SCENE02_PACKAGE_INSERT, SCENE03_ECOMMERCE, SCENE04_LOGISTICS_REF, SCENE05_BRAND_PARTNER, SCENE06_CAINIAO, REFERRAL, OTHER] } }
        - { name: status, in: query, schema: { type: string, enum: [NEW, CONTACTED, QUALIFIED, CONVERTED, LOST] } }
      responses:
        "200": { description: OK }
    post:
      tags: [05 运营服务 OPS]
      operationId: opsCreateLead
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_name, scene]
              properties:
                customer_name: { type: string }
                phone: { type: string, example: "91234567" }
                whatsapp: { type: string, example: "85291234567" }
                scene: { type: string, enum: [SCENE01_ONLINE_AD, SCENE02_PACKAGE_INSERT, SCENE03_ECOMMERCE, SCENE04_LOGISTICS_REF, SCENE05_BRAND_PARTNER, SCENE06_CAINIAO, REFERRAL, OTHER] }
                category_intent: { type: array, items: { type: string } }
                notes: { type: string }
      responses:
        "201": { description: Created }

  /ops/leads/{lead_id}/convert:
    parameters: [ { name: lead_id, in: path, required: true, schema: { type: string } } ]
    post:
      tags: [05 运营服务 OPS]
      summary: 线索转 CPT 订单（内部 302 → CPT POST）
      operationId: opsConvertLeadToOrder
      responses:
        "201":
          description: 订单创建成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_id: { type: string }

  /ops/rewards:
    get:
      tags: [05 运营服务 OPS]
      operationId: opsListRewards
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - { name: status, in: query, schema: { type: string, enum: [PENDING_APPROVAL, APPROVED, PAID, EXPIRED] } }
      responses:
        "200":
          description: 奖励列表
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        reward_id: { type: string }
                        referrer_customer_id: { type: string }
                        new_order_id: { type: string }
                        amount_hkd: { type: number }
                        status: { type: string }

  /ops/rewards/{reward_id}/approve:
    parameters: [ { name: reward_id, in: path, required: true, schema: { type: string } } ]
    post:
      tags: [05 运营服务 OPS]
      summary: BO 审批（≥500HKD）
      operationId: opsApproveReward
      responses: "200"

  /ops/rewards/{reward_id}/pay:
    parameters: [ { name: reward_id, in: path, required: true, schema: { type: string } } ]
    post:
      tags: [05 运营服务 OPS]
      summary: OL 发放（FPS + 收据 URL）
      operationId: opsMarkRewardPaid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [receipt_url]
              properties:
                receipt_url: { type: string, format: uri }
                notes: { type: string }
      responses: "200"

  /ops/wom/candidates:
    get:
      tags: [05 运营服务 OPS]
      summary: 口碑素材候选（L8 NPS≥9 自动入池，三状态）
      operationId: opsListWomCandidates
      parameters: [ { $ref: "#/components/parameters/page" }, { $ref: "#/components/parameters/page_size" } ]
      responses: "200"

  /ops/wom/candidates/{candidate_id}:
    parameters: [ { name: candidate_id, in: path, required: true, schema: { type: string } } ]
    patch:
      tags: [05 运营服务 OPS]
      summary: BO 审批 → 发布；或驳回
      operationId: opsReviewWomCandidate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [APPROVE_AND_PUBLISH, REJECT] }
                publish_url: { type: string, format: uri, description: APPROVE 必填 }
                reject_reason: { type: string }
      responses: "200"

  # ========== 06 调度服务 DSP ==========
  /dsp/orders/{order_id}/recommend-workers:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    get:
      tags: [06 调度服务 DSP]
      summary: OL 指派师傅时：Top3 推荐（技能匹配+负载+NPS 综合）
      operationId: dspRecommendWorkers
      responses:
        "200":
          description: 3 名师傅（按推荐得分降序）
          content:
            application/json:
              schema:
                type: array
                maxItems: 3
                items:
                  type: object
                  properties:
                    worker_id: { type: string }
                    score: { type: number, format: float }
                    match_reasons:
                      type: array
                      items: { type: string }
                    monthly_load_pct: { type: number, format: float, description: 本月已派工时 / 160h 目标 }

  /dsp/orders/{order_id}/assign:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    post:
      tags: [06 调度服务 DSP]
      summary: OL 确认指派（或改派）；自动同步 cpt_orders.assigned_worker_id + 阶段流转触发 L4→L5
      operationId: dspAssignWorker
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [worker_id]
              properties:
                worker_id: { type: string }
                expected_start_at: { type: string, format: date-time }
                notes: { type: string }
      responses:
        "200": { description: OK }
        "422": { description: 同日同时段 ≥ 2 单（CPT 3030）}

  /dsp/workers/load-board:
    get:
      tags: [06 调度服务 DSP]
      summary: 师傅负载看板（30 日每日条形图 + 颜色过载告警）
      operationId: dspWorkerLoadBoard
      responses: "200"

  # ========== 07 系统管理 ==========
  /admin/users:
    get:
      tags: [07 系统管理]
      operationId: adminListUsers
      parameters: [ { $ref: "#/components/parameters/page" }, { $ref: "#/components/parameters/page_size" } ]
      responses: "200"
    post:
      tags: [07 系统管理]
      operationId: adminCreateUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password, roles, display_name]
              properties:
                username: { type: string, minLength: 3, maxLength: 32 }
                password: { type: string, minLength: 10, maxLength: 64 }
                display_name: { type: string }
                roles:
                  type: array
                  minItems: 1
                  items: { type: string, enum: [SPO, BO, TL, OL, ML, FL] }
                worker_id: { type: string, description: 若 role=FL 必填，关联 WPL 档案 }
      responses:
        "201": { description: Created }
        "409": { $ref: "#/components/responses/Conflict" }

  /admin/users/{user_id}/roles:
    parameters: [ { name: user_id, in: path, required: true, schema: { type: string } } ]
    put:
      tags: [07 系统管理]
      operationId: adminUpdateUserRoles
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                roles:
                  type: array
                  items: { type: string, enum: [SPO, BO, TL, OL, ML, FL] }
      responses: "200"

  /admin/roles/{role_name}/permissions:
    parameters: [ { name: role_name, in: path, required: true, schema: { type: string, enum: [SPO, BO, TL, OL, ML, FL] } } ]
    get:
      tags: [07 系统管理]
      summary: 获取某角色 21 条权限开关
      operationId: adminGetRolePermissions
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: boolean
    put:
      tags: [07 系统管理]
      operationId: adminSetRolePermissions
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties:
                type: boolean
      responses: "200"

  /admin/audit-logs:
    get:
      tags: [07 系统管理]
      summary: 审计日志查询（1 年在线）
      operationId: adminListAuditLogs
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - { name: user_id, in: query, schema: { type: string } }
        - { name: action_type, in: query, schema: { type: string, enum: [LOGIN, LOGOUT, CREATE, UPDATE, DELETE, RBAC_CHANGE, EXPORT, FORCE_TRANSITION, RLS_CHANGE] } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: until, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        ts: { type: string, format: date-time }
                        user_id: { type: string }
                        action: { type: string }
                        target_entity: { type: string }
                        target_id: { type: string }
                        ip: { type: string }
                        success: { type: boolean }
                        note: { type: string, nullable: true }

  /admin/dlq:
    get:
      tags: [07 系统管理]
      operationId: adminListDlq
      parameters: [ { $ref: "#/components/parameters/page" }, { $ref: "#/components/parameters/page_size" }, { name: dead_type, in: query, schema: { type: string, enum: [FEISHU_SYNC, WEBHOOK_EVENT, CRON_JOB, REPORT_GENERATION, REWARD_CALC] } } ]
      responses:
        "200": { description: DLQ 列表 }
      description: ⏸ V2.0 暂不实施（阶段1 飞书对接暂缓，对齐 ADR-008）。端点结构保留便于未来激活，Code Agent 无需实现。

  /admin/dlq/{dlq_id}/replay:
    parameters: [ { name: dlq_id, in: path, required: true, schema: { type: string } } ]
    post:
      tags: [07 系统管理]
      operationId: adminReplayDlq
      responses: "200"
      description: ⏸ V2.0 暂不实施（阶段1 飞书对接暂缓）。

  /admin/feishu-sync/status:
    get:
      tags: [07 系统管理]
      summary: 飞书同步状态（6 表：SCL/BPL/CPL/WPL/QSV/CPT 延迟秒 + 成功/失败数）
      operationId: adminFeishuSyncStatus
      responses: "200"
      description: ⏸ V2.0 暂不实施（阶段1 飞书对接暂缓，对齐 ADR-008）。

  /admin/feishu-sync/full-resync:
    post:
      tags: [07 系统管理]
      summary: TL 二次确认后全量从飞书重同步（谨慎操作，写审计）
      operationId: adminFeishuFullResync
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm_text]
              properties:
                confirm_text:
                  type: string
                  enum: ["我已确认全量重同步影响，可能覆盖 PG 当前数据"]
      responses: "200"
      description: ⏸ V2.0 暂不实施（阶段1 飞书对接暂缓）。

  # ========== 08 客户端服务 CST（V2.0 新增，对应 cst-app React Native） ==========
  /cst/categories:
    get:
      tags: [08 客户端服务 CST]
      summary: 首页品类浏览（8 大品类入口，对齐 F-CST-002）
      operationId: cstListCategories
      responses:
        "200":
          description: 品类列表
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/SclCategory" }

  /cst/inquiries:
    post:
      tags: [08 客户端服务 CST]
      summary: 询价表单提交（对齐 F-CST-003，自动生成 ORD + L1→L2 流转）
      description: |
        客户端 App 询价入口。提交后：
        ① 生成 ORD-ULID，stage=L1，lead_source=CST_APP_SELF；
        ② 自动流转 L1→L2；
        ③ OL 后台收到新线索提醒；
        ④ 系统自动调 QSV 计算（F-CST-004 报价预览）。
      operationId: cstCreateInquiry
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CstInquiryRequest" }
      responses:
        "201":
          description: 询价创建成功
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CstInquiryResponse" }
        "422":
          description: 询价校验失败（3050）

  /cst/orders:
    get:
      tags: [08 客户端服务 CST]
      summary: 我的订单列表（进行中 + 已完成，对齐 F-CST-007/F-CST-012）
      operationId: cstListMyOrders
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [IN_PROGRESS, COMPLETED, ALL], default: ALL }
        - { $ref: "#/components/parameters/page" }
        - { $ref: "#/components/parameters/page_size" }
      responses:
        "200":
          description: 订单列表
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CstOrderListResponse" }

  /cst/orders/{order_id}:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    get:
      tags: [08 客户端服务 CST]
      summary: 订单跟踪 Timeline（L1-L8 8 阶段，对齐 F-CST-007）
      operationId: cstGetOrderTimeline
      responses:
        "200":
          description: 订单详情 + Timeline
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CstOrderTimelineResponse" }

  /cst/orders/{order_id}/quote:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    get:
      tags: [08 客户端服务 CST]
      summary: 查看报价（对齐 F-CST-004）
      operationId: cstGetQuote
      responses:
        "200":
          description: 报价详情（含 8 因子展开）
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QsvQuoteDetail" }

  /cst/orders/{order_id}/quote/confirm:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    post:
      tags: [08 客户端服务 CST]
      summary: 客户确认/拒绝报价（对齐 F-CST-005）
      operationId: cstConfirmQuote
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [CONFIRM, REJECT] }
                reject_reason: { type: string, nullable: true }
      responses:
        "200": { description: 报价状态已更新，触发 CPT L3→L4 流转（CONFIRM）或 EXCEPTION（REJECT） }

  /cst/orders/{order_id}/checkout:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    post:
      tags: [08 客户端服务 CST]
      summary: 在线下单（确认签约，对齐 F-CST-006）
      description: 客户在 App 内选择付款方式 + 首付比例 + 电子签字，订单 stage=L4。
      operationId: cstCheckout
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CstCheckoutRequest" }
      responses:
        "200": { description: 下单成功，订单 stage=L4 }

  /cst/orders/{order_id}/worker:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    get:
      tags: [08 客户端服务 CST]
      summary: 师傅信息展示（脱敏，对齐 F-CST-008）
      description: 返回师傅姓名/等级/技能徽章/历史 NPS，不返回电话（仅展示 OL 联系方式作为中介）。
      operationId: cstGetWorkerInfo
      responses:
        "200":
          description: 师傅卡片信息（脱敏）
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CstWorkerInfoResponse" }

  /cst/orders/{order_id}/review:
    parameters: [ { $ref: "#/components/parameters/order_id" } ]
    post:
      tags: [08 客户端服务 CST]
      summary: L8 评价（NPS + 评分 + 文字 + 照片，对齐 F-CST-011）
      description: |
        提交评价后触发 OrderCompleted 领域事件，启动 4 链路数据闭环：
        ① QMD actual_hours 统计更新；
        ② WPL avg_nps 重算（≥5 单才生效）；
        ③ CPL 打 HIGH/LOW_NPS 标签；
        ④ OPS 若 NPS≥9 自动入 WOM 素材候选 + 推荐奖励计算。
      operationId: cstSubmitReview
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CstReviewRequest" }
      responses:
        "200": { description: 评价提交成功，触发 4 链路数据闭环 }

  /cst/referral/code:
    post:
      tags: [08 客户端服务 CST]
      summary: 生成推荐码 + 分享链接（对齐 F-CST-013）
      operationId: cstGenerateReferralCode
      responses:
        "200":
          description: 推荐码 + 深链
          content:
            application/json:
              schema:
                type: object
                properties:
                  referral_code: { type: string, example: "WV9K3X" }
                  share_link: { type: string, example: "https://cst.weavely.hk/r/WV9K3X" }

  /cst/referral/rewards:
    get:
      tags: [08 客户端服务 CST]
      summary: 我的推荐奖励记录（对齐 F-CST-015）
      operationId: cstListReferralRewards
      parameters:
        - { $ref: "#/components/parameters/page" }
        - { $ref: "#/components/parameters/page_size" }
      responses:
        "200":
          description: 奖励列表
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    reward_id: { type: string }
                    referee_name: { type: string, description: 被推荐人姓名（脱敏） }
                    amount_hkd: { type: number }
                    status: { type: string, enum: [PENDING, APPROVED, PAID, EXPIRED] }
                    created_at: { type: string, format: date-time }

  /cst/profile:
    get:
      tags: [08 客户端服务 CST]
      summary: 个人档案查询（对齐 F-CST-016）
      operationId: cstGetProfile
      responses:
        "200":
          description: 个人档案
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CstProfileResponse" }
    put:
      tags: [08 客户端服务 CST]
      summary: 修改个人档案（联系方式变更触发 OTP 重新验证，对齐 F-CST-016）
      operationId: cstUpdateProfile
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CstProfileUpdateRequest" }
      responses:
        "200": { description: 档案已更新；若联系方式变更返回 need_otp_reverify=true }

  /cst/push-preferences:
    get:
      tags: [08 客户端服务 CST]
      summary: 推送偏好查询（对齐 F-CST-017）
      operationId: cstGetPushPreferences
      responses:
        "200":
          description: 8 类推送开关状态
          content:
            application/json:
              schema:
                type: object
                properties:
                  PUSH_NEW_ORDER: { type: boolean }
                  PUSH_ORDER_REMINDER_1H: { type: boolean }
                  PUSH_ORDER_STATUS: { type: boolean }
                  PUSH_WORKER_DEPARTED: { type: boolean }
                  PUSH_REVIEW_INVITE: { type: boolean }
                  PUSH_REFERRAL_REWARD: { type: boolean }
                  PUSH_QUOTE_READY: { type: boolean }
                  PUSH_SYSTEM: { type: boolean }
    put:
      tags: [08 客户端服务 CST]
      summary: 修改推送偏好（对齐 F-CST-017）
      operationId: cstUpdatePushPreferences
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                PUSH_NEW_ORDER: { type: boolean }
                PUSH_ORDER_REMINDER_1H: { type: boolean }
                PUSH_ORDER_STATUS: { type: boolean }
                PUSH_WORKER_DEPARTED: { type: boolean }
                PUSH_REVIEW_INVITE: { type: boolean }
                PUSH_REFERRAL_REWARD: { type: boolean }
                PUSH_QUOTE_READY: { type: boolean }
                PUSH_SYSTEM: { type: boolean }
      responses:
        "200": { description: 偏好已更新 }

  # ========== 09 移动认证 & 推送（V2.0 新增） ==========
  /auth/otp-send:
    post:
      tags: [09 移动认证 & 推送]
      summary: 发送 WhatsApp OTP（客户端登录，对齐 F-CST-001 / F-MOB-001）
      description: 6 位 OTP 通过 WhatsApp Business API 发送，5 分钟有效期。
      operationId: authOtpSend
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [whatsapp]
              properties:
                whatsapp: { type: string, example: "+852-98765432" }
                referral_code: { type: string, nullable: true, description: 若通过推荐链接注册，携带推荐码自动绑定 referrer_id（F-CST-014） }
      responses:
        "200": { description: OTP 已发送（返回 otp_session_id 用于验证） }
        "429": { description: 发送过于频繁（60s 内限 1 次） }

  /auth/otp-verify:
    post:
      tags: [09 移动认证 & 推送]
      summary: 验证 WhatsApp OTP 并签发 JWT（对齐 F-CST-001）
      description: |
        验证成功后签发 access_token + refresh_token，写入 cst_sessions 表。
        refresh_token 一次性轮换策略（F-MOB-001）：每次刷新后旧 token 加入黑名单。
      operationId: authOtpVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [otp_session_id, otp_code]
              properties:
                otp_session_id: { type: string }
                otp_code: { type: string, example: "123456" }
      responses:
        "200":
          description: 验证成功
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "401":
          description: OTP 验证失败（1004）

  /auth/biometric:
    post:
      tags: [09 移动认证 & 推送]
      summary: 生物识别登录（Face ID / Touch ID，对齐 F-MOB-001）
      description: |
        客户端调用 expo-local-authentication 完成生物识别后，
        将生物特征签名 + 已绑定的 refresh_token 提交后端校验，
        校验通过后签发新 access_token（免密登录）。
      operationId: authBiometric
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token, biometric_signature]
              properties:
                refresh_token: { type: string }
                biometric_signature: { type: string, description: 客户端生物识别签名（Base64） }
      responses:
        "200":
          description: 生物识别登录成功
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "401":
          description: Refresh Token 无效（1003）
        "403":
          description: 生物识别验证失败（1005）

  /push/register:
    post:
      tags: [09 移动认证 & 推送]
      summary: 注册 Expo Push Token（对齐 F-MOB-002）
      description: |
        师傅/客户首次登录 App 后调用，将 ExpoPushToken 注册到后端 mobile_push_tokens 表。
        后端按订单事件触发推送（8 类模板），通过 Expo Notifications API 统一发送至 APNs/FCM。
      operationId: pushRegisterToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [push_token, platform, app_type]
              properties:
                push_token: { type: string, description: ExpoPushToken（如 ExponentPushToken[xxxxxx]） }
                platform: { type: string, enum: [IOS, ANDROID] }
                app_type: { type: string, enum: [WKR, CST], description: 师傅端 / 客户端 }
      responses:
        "200": { description: Token 已注册/更新 }

  /push/send:
    post:
      tags: [09 移动认证 & 推送]
      summary: 发送推送（后端内部调用 / 测试用，对齐 F-MOB-002）
      description: |
        通常由后端订单事件自动触发（非前端直接调用）。
        提供此端点便于 TL 测试推送模板（TestFlight / Internal Testing 验证）。
      operationId: pushSend
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id, push_code, payload]
              properties:
                user_id: { type: string }
                push_code:
                  type: string
                  enum: [PUSH_NEW_ORDER, PUSH_ORDER_REMINDER_1H, PUSH_ORDER_STATUS, PUSH_WORKER_DEPARTED, PUSH_REVIEW_INVITE, PUSH_REFERRAL_REWARD, PUSH_QUOTE_READY, PUSH_SYSTEM]
                payload:
                  type: object
                  description: 模板参数（如 { category, date, slot, address }）
      responses:
        "200":
          description: 推送已发送
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent: { type: integer, description: 成功送达数 }
                  failed: { type: integer }
                  delivery_rate: { type: number, description: 送达率（0-1） }
        "422":
          description: 推送发送失败（3040）

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: "Access Token 有效期 2h；需刷新调用 /auth/refresh"

  parameters:
    page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    page_size:
      name: page_size
      in: query
      schema: { type: integer, minimum: 1, maximum: 500, default: 20 }
    order_id:
      name: order_id
      in: path
      required: true
      schema:
        type: string
        example: ORD-01HQWYX1B888XZ29KM63P5G6VV
    quote_id:
      name: quote_id
      in: path
      required: true
      schema:
        type: string
        example: QTE-01HQWYXXX...

  responses:
    Unauthorized:
      description: 未登录 / Token 过期（1001）
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            code: 1001
            message: Token expired
            trace_id: abc123
    Forbidden:
      description: 无权限 RBAC/RLS（1002）
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { code: 1002, message: qsv:params:edit required }
    Unprocessable:
      description: 参数 / 业务校验失败（2001 通用 / 3010 QSV / 3020 CPT / 3030 DSP）
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: 业务冲突（3001 状态流转 / 3002 唯一键）
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: 资源不存在（4001）
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:

    # ====== 基础 ======
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: integer
          description: DIP1-SPEC 附录 A 错误码（1001/1002/2001/3001/3010/3020/3030/4001/5001...）
        message: { type: string }
        trace_id: { type: string }
        details:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              issue: { type: string }

    HealthCheck:
      type: object
      required: [status, services]
      properties:
        status: { type: string, enum: [OK, DEGRADED, DOWN] }
        app_version: { type: string, example: 1.0.0 }
        services:
          type: object
          properties:
            postgres: { type: string, enum: [OK, DOWN] }
            redis: { type: string, enum: [OK, DOWN] }
            r2_storage: { type: string, enum: [OK, DOWN] }
            feishu_sync: { type: string, enum: [OK, PAUSED, ERROR] }
        ts: { type: string, format: date-time }

    # ====== 01 认证 ======
    LoginRequest:
      type: object
      required: [username, password]
      properties:
        username: { type: string, example: "ol_chengwen" }
        password: { type: string, minLength: 10, example: "**********" }
    LoginResponse:
      type: object
      properties:
        token_type: { type: string, enum: [Bearer] }
        access_token: { type: string }
        access_expires_in_sec: { type: integer, example: 7200 }
        refresh_token: { type: string }
        refresh_expires_in_sec: { type: integer, example: 2592000 }
        user: { $ref: "#/components/schemas/CurrentUser" }
    CurrentUser:
      type: object
      properties:
        user_id: { type: string }
        username: { type: string }
        display_name: { type: string }
        roles:
          type: array
          items: { type: string, enum: [SPO, BO, TL, OL, ML, FL] }
        worker_id: { type: string, nullable: true, description: 若含 FL 角色关联 WPL }
        permissions:
          type: array
          description: "用户全部权限 key 列表（RBAC 展开后的 21 条子集）"
          items: { type: string }
        rls_scopes:
          type: object
          description: RLS 物理限制范围说明
          additionalProperties: true

    # ====== 02 QSV ======
    QsvCalculateRequest:
      type: object
      required: [category_id]
      properties:
        category_id: { type: string, description: SCL 品类 ID }
        building_id: { type: string, description: BPL 楼宇 ID；若未提供则需要传 building_factor_override }
        building_factor_override: { type: number, format: float, minimum: 0.5, maximum: 2.0 }
        worker_level: { type: string, enum: [L1, L2, L3], default: L2 }
        dimensions_cm:
          type: object
          properties:
            width: { type: number }
            height: { type: number }
            depth: { type: number }
        addons:
          type: array
          items:
            type: object
            required: [name, qty, unit_price_hkd]
            properties:
              name: { type: string }
              qty: { type: integer, minimum: 1 }
              unit_price_hkd: { type: number }
        discount_pct: { type: number, format: float, minimum: 0, maximum: 50, description: 0-50；>15 自动 BO 审批 }
        peak_factor_override: { type: number, format: float, default: 1 }
        channel_template_id: { type: string, nullable: true }
        order_id: { type: string, nullable: true }
        param_version_id: { type: string, nullable: true, description: 默认最新 }

    QsvCalculateResponse:
      type: object
      required: [base_fee, factor_total, subtotal_hkd, cap_price_hkd, final_hkd, factor_breakdown, snapshot]
      properties:
        base_fee: { type: number }
        factor_total: { type: number, format: float }
        addons_total_hkd: { type: number }
        discount_total_hkd: { type: number }
        subtotal_hkd: { type: number }
        cap_price_hkd: { type: number }
        final_hkd: { type: number }
        capped: { type: boolean, description: true=触发封顶 }
        bo_approval_required: { type: boolean }
        factor_breakdown:
          type: array
          items:
            type: object
            properties:
              factor_key:
                type: string
                enum: [CATEGORY_BASE, BUILDING, FLOOR, ELEVATOR, WORKER_LEVEL, SEASON_PEAK, DIMENSION_VOLUME, CHANNEL]
              factor_value: { type: number, format: float }
              impact_hkd: { type: number }
        snapshot: { type: object, additionalProperties: true }

    QsvQuote:
      type: object
      properties:
        quote_id: { type: string }
        order_id: { type: string, nullable: true }
        customer_id: { type: string }
        category_id: { type: string }
        status: { type: string, enum: [DRAFT, CONFIRMED, REJECTED, EXPIRED] }
        final_hkd: { type: number }
        created_at: { type: string, format: date-time }
        confirmed_at: { type: string, format: date-time, nullable: true }
        param_version_id: { type: string }

    QsvQuoteDetail:
      allOf:
        - $ref: "#/components/schemas/QsvQuote"
        - type: object
          properties:
            calc: { $ref: "#/components/schemas/QsvCalculateResponse" }
            audit:
              type: array
              items: { type: object }

    QsvQuotePage:
      type: object
      properties:
        total: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/QsvQuote" }

    QsvCreateQuoteRequest:
      allOf:
        - $ref: "#/components/schemas/QsvCalculateRequest"
        - type: object
          properties:
            order_id: { type: string, nullable: true }
            customer_id: { type: string }

    QsvParamVersion:
      type: object
      properties:
        version_id: { type: string }
        version_code: { type: string, example: V7 }
        effective_from: { type: string, format: date }
        created_by: { type: string }
        note: { type: string }
        factors_count: { type: integer }

    # ====== 03 CPT ======
    CptOrderCard:
      type: object
      properties:
        order_id: { type: string }
        customer_name: { type: string }
        trust_badges:
          type: array
          items: { type: string, enum: [LOYAL_3, HIGH_RECOMMEND, VIP_BRAND_PARTNER] }
        exception_tag: { type: boolean }
        stage: { type: string, enum: [L1, L2, L3, L4, L5, L6, L7, L8, DONE] }
        category_id: { type: string }
        quote_final_hkd: { type: number, nullable: true }
        building_short_addr: { type: string }
        assigned_worker_name: { type: string, nullable: true }
        assigned_worker_level: { type: string, nullable: true }
        expected_start_hhmm: { type: string, nullable: true }
        follow_up_name: { type: string }
        pool_label: { type: string, enum: [TODAY, OVERDUE, APPOINTMENT_SOON, NORMAL] }

    CptOrder:
      type: object
      properties:
        order_id: { type: string }
        stage: { type: string, enum: [L1, L2, L3, L4, L5, L6, L7, L8, DONE] }
        status: { type: string, enum: [IN_PROGRESS, COMPLETED, CANCELLED, EXCEPTION_PAUSED] }
        lead_source_scene: { type: string }
        customer_name: { type: string }
        customer_phone_masked: { type: string }
        customer_whatsapp_masked: { type: string }
        customer_id: { type: string, nullable: true }
        category_id: { type: string }
        product_model: { type: string, nullable: true }
        building_id: { type: string, nullable: true }
        address_detail: { type: string, nullable: true }
        assigned_worker_id: { type: string, nullable: true }
        follow_up_id: { type: string, nullable: true }
        latest_quote_final_hkd: { type: number, nullable: true }
        expected_start_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        last_updated_at: { type: string, format: date-time }

    CptOrderDetail:
      allOf:
        - $ref: "#/components/schemas/CptOrder"
        - type: object
          properties:
            stages:
              type: array
              items: { $ref: "#/components/schemas/CptOrderStage" }
            active_quotes:
              type: array
              items: { $ref: "#/components/schemas/QsvQuote" }
            attachments:
              type: array
              items:
                type: object
                properties:
                  attachment_id: { type: string }
                  category: { type: string }
                  url: { type: string, format: uri }
                  content_type: { type: string }
                  size_bytes: { type: integer }
                  uploaded_by: { type: string }
                  uploaded_at: { type: string, format: date-time }
            audit_log:
              type: array
              items:
                type: object
                properties:
                  at: { type: string, format: date-time }
                  by: { type: string }
                  action: { type: string }
                  payload: { type: object, additionalProperties: true }

    CptOrderStage:
      type: object
      required: [stage, status]
      properties:
        stage: { type: string, enum: [L1, L2, L3, L4, L5, L6, L7, L8, DONE, EXCEPTION] }
        status: { type: string, enum: [PENDING, IN_PROGRESS, DONE, SKIPPED] }
        entered_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        operator_id: { type: string, nullable: true }
        data:
          type: object
          additionalProperties: true
        exceptions:
          type: array
          items:
            type: object
            properties:
              level: { type: string, enum: [L1, L2, L3] }
              reason: { type: string }
              at: { type: string, format: date-time }

    CptCreateOrderRequest:
      type: object
      required: [customer_name, lead_source_scene]
      properties:
        lead_source_scene: { type: string }
        referral_from_customer_id: { type: string, nullable: true }
        customer_name: { type: string }
        phone: { type: string }
        whatsapp: { type: string }
        email: { type: string, nullable: true }
        category_intent: { type: string }
        initial_notes: { type: string }

    CptL6Data:
      type: object
      required: [worker_departed_at, worker_arrived_at, install_finished_at, six_s_items, install_photo_entry_ids, install_photo_process_ids, install_photo_after_ids]
      properties:
        worker_departed_at: { type: string, format: date-time }
        worker_arrived_at: { type: string, format: date-time }
        install_finished_at: { type: string, format: date-time }
        rest_minutes: { type: integer, minimum: 0, maximum: 480 }
        actual_hours_manual_override: { type: number, format: float, nullable: true, description: 触发审计日志 }
        six_s_items:
          type: object
          required: [shoe_covers, floor_protection, workbench_tidy, trash_taken, product_clean, before_after_photos]
          properties:
            shoe_covers: { type: boolean }
            floor_protection: { type: boolean }
            workbench_tidy: { type: boolean }
            trash_taken: { type: boolean }
            product_clean: { type: boolean }
            before_after_photos: { type: boolean }
        install_photo_entry_ids: { type: array, items: { type: string }, minItems: 1 }
        install_photo_process_ids: { type: array, items: { type: string }, minItems: 1 }
        install_photo_after_ids: { type: array, items: { type: string }, minItems: 2 }
        addons:
          type: array
          items:
            type: object
            properties:
              description: { type: string }
              estimated_amount_hkd: { type: number }
              customer_confirm_screenshot_id: { type: string, nullable: true }
        exceptions:
          type: array
          items:
            type: object
            properties:
              severity: { type: string, enum: [MILD, MEDIUM, SEVERE] }
              kind: { type: string, enum: [PRODUCT_BROKEN, PART_MISSING, INSTALL_CONDITION, PROPERTY_BLOCK, RESCHEDULE, OTHER] }
              notes: { type: string }
              photo_attachment_ids: { type: array, items: { type: string } }

    CptL7Data:
      type: object
      required: [result, signature_photo_id, accepted_at]
      properties:
        result: { type: string, enum: [PASS, RECTIFY, COMPLAINT] }
        rectify_items:
          type: array
          items:
            type: object
            properties:
              description: { type: string }
              photo_ids: { type: array, items: { type: string } }
              expected_rework_date: { type: string, format: date }
        signature_photo_id: { type: string, description: 签字照附件 ID }
        accepted_at: { type: string, format: date-time }

    CptL8Data:
      type: object
      required: [nps_score, follow_up_dimensions]
      properties:
        nps_score: { type: integer, minimum: 0, maximum: 10 }
        follow_up_dimensions:
          type: object
          properties:
            attitude: { type: boolean, description: 师傅服务态度满意 }
            quality: { type: boolean, description: 安装质量满意 }
            price: { type: boolean, description: 价格合理 }
            speed: { type: boolean, description: 时效满意 }
            referral: { type: boolean, description: 推荐意愿 }
        notes: { type: string, maxLength: 2000 }
        wom_consent_given: { type: boolean, default: false, description: 客户同意素材用于口碑营销 }

    # ====== 04 MDS 四库 ======
    CplCustomer:
      type: object
      properties:
        customer_id: { type: string }
        name: { type: string }
        phone_masked: { type: string, description: 9123****（非 TL/BO 脱敏）}
        whatsapp_masked: { type: string }
        email_masked: { type: string, nullable: true }
        first_order_scene: { type: string, nullable: true }
        total_orders: { type: integer }
        avg_nps: { type: number, format: float, nullable: true }
        trust_badges:
          type: array
          items: { type: string, enum: [LOYAL_3, HIGH_RECOMMEND, VIP_BRAND_PARTNER] }

    CplCustomerUpsert:
      type: object
      required: [name]
      properties:
        name: { type: string }
        phone: { type: string, nullable: true }
        whatsapp: { type: string, nullable: true }
        email: { type: string, nullable: true }
        first_order_scene: { type: string, nullable: true }
        referral_from_customer_id: { type: string, nullable: true }
        tag_ids_to_apply: { type: array, items: { type: string } }

    WplWorker:
      type: object
      properties:
        worker_id: { type: string }
        display_name: { type: string }
        level: { type: string, enum: [L1, L2, L3] }
        status: { type: string, enum: [AVAILABLE, BUSY, ON_LEAVE, SUSPENDED] }
        phone_masked: { type: string }
        joined_at: { type: string, format: date }
        total_orders: { type: integer }
        avg_nps: { type: number, format: float, nullable: true }

    WplWorkerUpsert:
      type: object
      required: [display_name, level]
      properties:
        display_name: { type: string }
        level: { type: string, enum: [L1, L2, L3] }
        status: { type: string, enum: [AVAILABLE, BUSY, ON_LEAVE, SUSPENDED] }
        phone: { type: string }
        emergency_contact: { type: string, nullable: true }
        joined_at: { type: string, format: date }
        categories_can_do:
          type: array
          items: { type: string, enum: [WARDROBE, KITCHEN_CABINET, BED, DINING_TABLE, BOOKSHELF, TV_CABINET, OFFICE_DESK, COMBINATION_FURNITURE] }
        skill_matrix_custom:
          type: object
          additionalProperties:
            type: integer
            minimum: 0
            maximum: 5

    SclCategory:
      type: object
      properties:
        category_id: { type: string }
        code: { type: string, enum: [WARDROBE, KITCHEN_CABINET, BED, DINING_TABLE, BOOKSHELF, TV_CABINET, OFFICE_DESK, COMBINATION_FURNITURE] }
        name_zh: { type: string }
        base_fee_hkd: { type: number }
        base_hours: { type: number, format: float }
        cap_price_hkd: { type: number }
        required_worker_level: { type: string, enum: [L1, L2, L3] }
        sub_categories:
          type: array
          items:
            type: object
            properties:
              sub_id: { type: string }
              name: { type: string }
              base_fee_delta_pct: { type: number, format: float }

    BplBuilding:
      type: object
      properties:
        building_id: { type: string }
        address: { type: string }
        district: { type: string, description: 香港 18 区枚举之一 }
        type: { type: string, enum: [PUBLIC_HOUSING, HOS, PRIVATE, VILLAGE_HOUSE, COMMERCIAL, INDUSTRIAL, OTHER] }
        total_floors: { type: integer }
        has_elevator: { type: boolean }
        parking_fee_hkd: { type: number, nullable: true }
        verified_by_worker: { type: boolean }
        orders_count: { type: integer }
        tags:
          type: array
          items: { type: string }

    BplBuildingUpsert:
      type: object
      required: [address, type]
      properties:
        address: { type: string }
        district: { type: string }
        type: { type: string, enum: [PUBLIC_HOUSING, HOS, PRIVATE, VILLAGE_HOUSE, COMMERCIAL, INDUSTRIAL, OTHER] }
        total_floors: { type: integer, nullable: true }
        has_elevator: { type: boolean, default: true }
        parking_fee_hkd: { type: number, nullable: true }
        property_access_note: { type: string, nullable: true }

    # ========== V2.0 新增：CST 客户端 Schemas ==========
    CstInquiryRequest:
      type: object
      required: [category_id, product_model, address, preferred_slot, site_conditions]
      properties:
        category_id: { type: string, example: "CAT-WARDROBE" }
        product_model: { type: string, example: "IKEA PAX 200x236" }
        size_info: { type: string, nullable: true, example: "W200 x D58 x H236 cm" }
        address: { type: string, description: 完整地址文本 }
        building_id: { type: string, nullable: true, description: 若 BPL 命中则带 building_id }
        preferred_slot:
          type: object
          properties:
            date: { type: string, format: date }
            slot: { type: string, enum: [MORNING_09_13, AFTERNOON_13_18, WEEKEND_PRIORITY] }
        site_conditions:
          type: object
          properties:
            elevator_available: { type: boolean }
            truck_accessible: { type: boolean }
            parking_available: { type: boolean }
            property_permit_required: { type: boolean }
            corridor_restriction: { type: boolean }
            note: { type: string, nullable: true }
        doorframe_photos:
          type: array
          description: 门框/楼道照片 URL 列表（最多 9 张，由 /cpt/orders/{id}/attachments 预上传返回）
          items: { type: string }
          maxItems: 9

    CstInquiryResponse:
      type: object
      properties:
        order_id: { type: string, example: "ORD-01AN4Z07BY79KA1307SR9X4MV3" }
        stage: { type: string, enum: [L2], description: 自动从 L1 流转到 L2 }
        lead_source: { type: string, enum: [CST_APP_SELF] }
        quote_preview: { $ref: "#/components/schemas/QsvQuoteDetail", description: F-CST-004 报价预览（DRAFT 状态） }

    CstOrderListResponse:
      type: object
      properties:
        total: { type: integer }
        page: { type: integer }
        page_size: { type: integer }
        items:
          type: array
          items:
            type: object
            properties:
              order_id: { type: string }
              category_name: { type: string }
              stage: { type: string, enum: [L1, L2, L3, L4, L5, L6, L7, L8, EXCEPTION, DONE] }
              status: { type: string, enum: [IN_PROGRESS, COMPLETED, CANCELLED, EXCEPTION_PAUSED] }
              scheduled_date: { type: string, format: date, nullable: true }
              final_amount_hkd: { type: number, nullable: true }
              worker_name: { type: string, nullable: true, description: 脱敏后的师傅姓名 }
              updated_at: { type: string, format: date-time }

    CstOrderTimelineResponse:
      type: object
      properties:
        order_id: { type: string }
        current_stage: { type: string }
        timeline:
          type: array
          items:
            type: object
            properties:
              stage: { type: string, enum: [L1, L2, L3, L4, L5, L6, L7, L8] }
              stage_name: { type: string, example: "线索获取" }
              status: { type: string, enum: [PENDING, IN_PROGRESS, COMPLETED, SKIPPED] }
              entered_at: { type: string, format: date-time, nullable: true }
              completed_at: { type: string, format: date-time, nullable: true }
              summary: { type: string, description: 该阶段简要说明 }
        quote: { $ref: "#/components/schemas/QsvQuoteDetail", nullable: true }
        worker: { $ref: "#/components/schemas/CstWorkerInfoResponse", nullable: true }

    CstCheckoutRequest:
      type: object
      required: [payment_method, deposit_ratio, e_signature]
      properties:
        payment_method: { type: string, enum: [FPS, CREDIT_CARD, CASH, MONTHLY] }
        deposit_ratio: { type: number, description: "首付比例 0-1（如 0.5 = 50%）" }
        e_signature: { type: string, description: 客户电子签字（Base64 PNG） }

    CstWorkerInfoResponse:
      type: object
      description: 师傅卡片信息（脱敏，不含电话）
      properties:
        worker_id: { type: string }
        display_name: { type: string, description: 脱敏姓名（如 "陈师傅"） }
        level: { type: string, enum: [L1, L2, L3] }
        skill_badges:
          type: array
          items: { type: string, example: "衣柜专家" }
        avg_nps: { type: number, nullable: true }
        total_orders: { type: integer }
        ol_contact: { type: string, description: OL 联系方式（作为中介） }

    CstReviewRequest:
      type: object
      required: [nps_score, star_rating]
      properties:
        nps_score: { type: integer, minimum: 0, maximum: 10, description: NPS 0-10 分 }
        star_rating: { type: integer, minimum: 1, maximum: 5, description: 5 星评分 }
        text_review: { type: string, maxLength: 500, nullable: true }
        photo_urls:
          type: array
          items: { type: string }
          maxItems: 5

    CstProfileResponse:
      type: object
      properties:
        customer_id: { type: string }
        name: { type: string }
        whatsapp: { type: string, description: 脱敏展示（如 +852-9876****） }
        email: { type: string, nullable: true }
        address: { type: string, nullable: true }
        trust_badges:
          type: array
          items: { type: string }
        total_orders: { type: integer }
        lifetime_value_hkd: { type: number }

    CstProfileUpdateRequest:
      type: object
      properties:
        name: { type: string, nullable: true }
        whatsapp: { type: string, nullable: true, description: 修改后触发 OTP 重新验证 }
        email: { type: string, nullable: true }
        address: { type: string, nullable: true }
