openapi: 3.0.3
info:
  title: HomeX API
  description: |
    HomeX O2O建材商城+师傅平台 API 文档。

    ## 认证
    所有需要认证的端点使用 Bearer JWT。

    ## 货币
    所有金额以印尼盾(IDR)表示，单位为分(IDR×100)，使用 BIGINT。
    例如：Rp 68.000 = 6800000

    ## 语言
    - 请求头 `Accept-Language`: en | id | zh
    - 错误消息和返回文案按此 locale 返回

  version: 1.0.0
  contact:
    name: HomeX Platform
    url: https://homex.id

servers:
  - url: https://api.homex.id/v1
    description: Production
  - url: https://staging-api.homex.id/v1
    description: Staging
  - url: http://localhost:8080/v1
    description: Local dev

security:
  - bearerAuth: []

tags:
  - name: Auth
    description: 认证与登录
  - name: Stores
    description: 门店
  - name: Products
    description: 商品与定价
  - name: Cart
    description: 购物车
  - name: Checkout
    description: 结算
  - name: Payments
    description: 支付
  - name: Orders
    description: 订单
  - name: Services
    description: 师傅服务目录
  - name: Tukang
    description: 师傅合伙人
  - name: Ops
    description: 门店运营
  - name: Tracking
    description: 实时追踪
  - name: Member
    description: 会员中心

paths:
  # ================================================================ AUTH
  /auth/whatsapp-otp:
    post:
      tags: [Auth]
      summary: 发送 WhatsApp OTP
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, example: "+6281212244334" }
      responses:
        '200':
          description: OTP 已发送
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  retry_after: { type: integer, example: 60, description: 重新发送等待秒数 }
        '429': { $ref: '#/components/responses/RateLimited' }

  /auth/verify-otp:
    post:
      tags: [Auth]
      summary: 验证 OTP 登录
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone, otp]
              properties:
                phone: { type: string, example: "+6281212244334" }
                otp: { type: string, example: "123456" }
                referral_code: { type: string, description: 邀请码(首次注册时绑定) }
      responses:
        '200':
          description: 登录成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }
                  user: { $ref: '#/components/schemas/User' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /auth/refresh:
    post:
      tags: [Auth]
      summary: 刷新 Token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        '200':
          description: 新 Token 对
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }

  # ================================================================ STORES
  /stores:
    get:
      tags: [Stores]
      summary: 附近门店列表
      parameters:
        - name: lat
          in: query
          schema: { type: number, example: -6.2 }
        - name: lng
          in: query
          schema: { type: number, example: 106.8 }
        - name: radius
          in: query
          schema: { type: integer, default: 50, description: 搜索半径km }
      responses:
        '200':
          description: 门店列表
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Store' }

  /stores/{id}:
    get:
      tags: [Stores]
      summary: 门店详情
      parameters:
        - $ref: '#/components/parameters/StoreId'
      responses:
        '200':
          description: 门店详情
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Store' }
        '404': { $ref: '#/components/responses/NotFound' }

  /stores/{id}/products:
    get:
      tags: [Stores]
      summary: 门店商品列表
      parameters:
        - $ref: '#/components/parameters/StoreId'
        - name: cat
          in: query
          schema: { type: string, description: 品类过滤 }
        - name: sort
          in: query
          schema: { type: string, enum: [hot, price_asc, price_desc, rating], default: hot }
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: 商品列表(含实时库存)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ProductWithStock' }
                  meta: { $ref: '#/components/schemas/Pagination' }

  # ================================================================ PRODUCTS
  /products/{id}:
    get:
      tags: [Products]
      summary: 商品详情
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: "P-1002" }
        - name: store_id
          in: query
          required: true
          schema: { type: string, example: "JKT-01" }
      responses:
        '200':
          description: 商品详情
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ProductDetail' }

  /products/{id}/price:
    get:
      tags: [Products]
      summary: 价格计算（价格解析器）
      description: |
        final = retail × (1 − tierOff%) × (1 − volOff%)
        带分类折扣上限保护。
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: tier
          in: query
          schema: { type: string, enum: [bronze, silver, gold, platinum, contractor] }
        - name: qty
          in: query
          schema: { type: integer, default: 1 }
        - name: store_id
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: 计算结果
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PriceResult' }

  # ================================================================ CART
  /cart:
    get:
      tags: [Cart]
      summary: 购物车（按门店分组）
      responses:
        '200':
          description: 购物车
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CartStoreGroup' }

  /cart/add:
    post:
      tags: [Cart]
      summary: 添加商品到购物车
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [store_id, product_id, qty]
              properties:
                store_id: { type: string, example: "JKT-01" }
                product_id: { type: string, example: "P-1002" }
                qty: { type: integer, example: 24 }
                add_install:
                  type: boolean
                  default: false
                  description: 自动添加安装服务子行
      responses:
        '200':
          description: 添加成功

  /cart/{lineId}:
    put:
      tags: [Cart]
      summary: 修改数量（子行联动）
      parameters:
        - name: lineId
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                qty: { type: integer, example: 30 }
    delete:
      tags: [Cart]
      summary: 删除行（子行自动删除）
      parameters:
        - name: lineId
          in: path
          required: true
          schema: { type: integer }

  # ================================================================ CHECKOUT
  /checkout:
    post:
      tags: [Checkout]
      summary: 结算（多门店拆单+单一支付）
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [delivery]
              properties:
                delivery:
                  type: array
                  items:
                    type: object
                    properties:
                      store_id: { type: string }
                      mode: { type: string, enum: [tukang, truck, pickup] }
                      address_id: { type: integer, description: 配送地址ID(pickup模式可空) }
                      scheduled_at: { type: string, format: date-time }
      responses:
        '200':
          description: 结算成功，返回拆分后的订单和待支付总额
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: array
                    items: { $ref: '#/components/schemas/Order' }
                  total_amount: { type: integer, example: 313450000, description: 总额(分) }

  # ================================================================ PAYMENTS
  /payments/create:
    post:
      tags: [Payments]
      summary: 创建支付
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: integer, example: 313450000, description: 金额(分) }
                method:
                  type: string
                  enum: [qris, va, ewallet, card, cash_store, cash_tukang, cod, indomaret, alfamart, bnpl, split]
                method_detail:
                  type: object
                  description: '{"va_bank":"bca"} 或 {"ewallet":"gopay"} 或 {"bnpl":"kredivo","tenure":6}'
                order_ids:
                  type: array
                  items: { type: integer }
      responses:
        '200':
          description: 支付创建成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  payment_id: { type: integer }
                  status: { type: string, enum: [pending] }
                  gateway_payload:
                    type: object
                    description: '聚合器返回数据 — QRIS二维码/VA号/支付链接等'

  /payments/{id}:
    get:
      tags: [Payments]
      summary: 查询支付状态
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: 支付详情

  /payments/callback:
    post:
      tags: [Payments]
      summary: 支付回调 (webhook)
      security: []
      description: Midtrans / Xendit 回调入口
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        '200':
          description: 回调已接收

  # ================================================================ ORDERS
  /orders:
    get:
      tags: [Orders]
      summary: 订单列表
      parameters:
        - name: type
          in: query
          schema: { type: string, enum: [goods, service] }
        - name: status
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: 订单列表
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Order' }
                  meta: { $ref: '#/components/schemas/Pagination' }

  /orders/{id}:
    get:
      tags: [Orders]
      summary: 订单详情
      parameters:
        - $ref: '#/components/parameters/OrderId'
      responses:
        '200':
          description: 订单详情
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/OrderDetail' }

  /orders/{id}/timeline:
    get:
      tags: [Orders]
      summary: 订单时间线
      parameters:
        - $ref: '#/components/parameters/OrderId'
      responses:
        '200':
          description: 时间线事件列表

  /orders/{id}/confirm:
    post:
      tags: [Orders]
      summary: 客户确认完工（触发担保资金释放）
      parameters:
        - $ref: '#/components/parameters/OrderId'
      responses:
        '200':
          description: 已确认，担保资金释放

  # ================================================================ SERVICES
  /services/categories:
    get:
      tags: [Services]
      summary: 服务品类列表
      responses:
        '200':
          description: 服务品类

  /services/{jobType}:
    get:
      tags: [Services]
      summary: 工种详情（含一口价+附加费）
      parameters:
        - name: jobType
          in: path
          required: true
          schema: { type: string }
        - name: city
          in: query
          schema: { type: string, default: JKT }
      responses:
        '200':
          description: 工种详情

  /services/book:
    post:
      tags: [Services]
      summary: 预约服务
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [service_id, store_id]
              properties:
                service_id: { type: string }
                store_id: { type: string }
                qty: { type: number, default: 1, description: 数量(m²/台/次) }
                scheduled_at: { type: string, format: date-time }
                address_id: { type: integer }
                remark: { type: string }
      responses:
        '200':
          description: 预约成功

  /services/{id}/extra-charge:
    post:
      tags: [Services]
      summary: 附加费请求（师傅发起）
      parameters:
        - $ref: '#/components/parameters/OrderId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, reason, photos]
              properties:
                amount: { type: integer, description: 附加费金额(分) }
                reason: { type: string, example: "wall_repair_needed" }
                photos:
                  type: array
                  items: { type: string }
      responses:
        '200':
          description: 附加费请求已提交，等待客户确认

  # ================================================================ TUKANG
  /tukang/register:
    post:
      tags: [Tukang]
      summary: 师傅注册（五关流程）
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                gate:
                  type: integer
                  enum: [1, 2, 3, 4, 5]
                  description: '关卡: 1=KTP 2=selfie 3=skill_declare 4=test 5=deposit'
                data:
                  type: object
                  description: 各关卡所需数据
      responses:
        '200':
          description: 关卡通过

  /tukang/offers:
    get:
      tags: [Tukang]
      summary: 待确认派单
      responses:
        '200':
          description: 派单列表(含90秒倒计时)

  /tukang/offers/{id}/accept:
    post:
      tags: [Tukang]
      summary: 接单
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200': { description: 已接单 }

  /tukang/offers/{id}/decline:
    post:
      tags: [Tukang]
      summary: 拒绝（需原因码）
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, enum: [wrong_trade, too_far, week_full, vehicle_mismatch, personal] }
      responses:
        '200': { description: 已拒绝 }

  /tukang/jobs:
    get:
      tags: [Tukang]
      summary: 我的工单
      responses:
        '200':
          description: 工单列表

  /tukang/jobs/{id}/pickup:
    post:
      tags: [Tukang]
      summary: 门店取货（扫交接码）
      parameters: [{ $ref: '#/components/parameters/OrderId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [handover_code]
              properties:
                handover_code: { type: string, example: "538291" }
                load_photo_url: { type: string, description: 已装载照片(车牌可见) }
      responses:
        '200': { description: 取货确认成功 }

  /tukang/jobs/{id}/checkin:
    post:
      tags: [Tukang]
      summary: GPS 签到（限位150m）
      parameters: [{ $ref: '#/components/parameters/OrderId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [gps_lat, gps_lng]
              properties:
                gps_lat: { type: number }
                gps_lng: { type: number }
                gps_accuracy: { type: number }
      responses:
        '200': { description: 签到成功 }
        '400':
          description: GPS 距离过远（>150m）

  /tukang/jobs/{id}/photos:
    post:
      tags: [Tukang]
      summary: 上传施工照片
      parameters: [{ $ref: '#/components/parameters/OrderId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [photo_type, photo_url]
              properties:
                photo_type: { type: string, enum: [before_photo, during_photo, after_photo, leftover_material] }
                photo_url: { type: string }
      responses:
        '200': { description: 照片已上传 }

  /tukang/jobs/{id}/complete:
    post:
      tags: [Tukang]
      summary: 完工签收
      parameters: [{ $ref: '#/components/parameters/OrderId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_code]
              properties:
                customer_code: { type: string, description: 客户4位确认码 }
                leftover_qty: { type: number, description: 剩余材料数量(触发退款) }
      responses:
        '200': { description: 完工确认 }

  /tukang/wallet:
    get:
      tags: [Tukang]
      summary: 收入钱包
      responses:
        '200':
          description: 钱包余额

  /tukang/withdraw:
    post:
      tags: [Tukang]
      summary: 提现
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, bank_account_id]
              properties:
                amount: { type: integer, description: 提现金额(分) }
                bank_account_id: { type: integer }
      responses:
        '200': { description: 提现申请已提交 }

  # ================================================================ OPS
  /ops/dashboard:
    get:
      tags: [Ops]
      summary: 门店看板
      parameters:
        - name: store_id
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: 看板数据

  /ops/dispatch-board:
    get:
      tags: [Ops]
      summary: 派遣看板（师傅实时地图位置）
      parameters:
        - name: store_id
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: 待派工单 + 在线师傅位置

  /ops/dispatch:
    post:
      tags: [Ops]
      summary: 派遣师傅
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_id, tukang_id]
              properties:
                order_id: { type: integer }
                tukang_id: { type: integer }
                override_reason: { type: string, description: 覆盖自动匹配需记录原因 }
      responses:
        '200': { description: 已派遣 }

  /ops/handover:
    post:
      tags: [Ops]
      summary: 柜台交接（生成6位码）
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_id, tukang_id]
              properties:
                order_id: { type: integer }
                tukang_id: { type: integer }
                verified_lines:
                  type: array
                  items: { type: integer }
                  description: 逐行确认的 order_line IDs
                load_weight_kg: { type: integer }
      responses:
        '200':
          description: 交接码已生成
          content:
            application/json:
              schema:
                type: object
                properties:
                  handover_code: { type: string, example: "538291" }

  /ops/pick-list/{orderId}:
    get:
      tags: [Ops]
      summary: 拣货单
      parameters:
        - $ref: '#/components/parameters/OrderId'
      responses:
        '200':
          description: 拣货单（按货架路线排序）

  /ops/pick-list/{lineId}/scan:
    post:
      tags: [Ops]
      summary: 条码扫描确认
      parameters:
        - name: lineId
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [barcode]
              properties:
                barcode: { type: string }
      responses:
        '200': { description: 扫描确认 }
        '400': { description: 条码不匹配 }

  /ops/stock:
    get:
      tags: [Ops]
      summary: 库存管理
      parameters:
        - name: store_id
          in: query
          required: true
          schema: { type: string }
      responses:
        '200': { description: 库存列表 }

  /ops/proxy-order:
    post:
      tags: [Ops]
      summary: 代客下单（Walk-in注册为会员）
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                customer_phone: { type: string }
                customer_name: { type: string }
                items:
                  type: array
                  items: { type: object }
      responses:
        '200': { description: 代客下单成功 }

  # ================================================================ TRACKING
  /track/{orderId}:
    get:
      tags: [Tracking]
      summary: 获取师傅实时位置+ETA
      parameters:
        - $ref: '#/components/parameters/OrderId'
      responses:
        '200':
          description: 实时位置
          content:
            application/json:
              schema:
                type: object
                properties:
                  phase: { type: string, enum: [assigned, to_pickup, collecting, en_route, arrived, installing, signed_off] }
                  tukang:
                    type: object
                    properties:
                      name: { type: string }
                      avatar: { type: string }
                      vehicle_plate: { type: string }
                      lat: { type: number }
                      lng: { type: number }
                  eta_minutes: { type: integer }
                  handover_confirmed: { type: boolean, description: 门店交接是否已完成 }

# ================================================================ COMPONENTS
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    StoreId:
      name: id
      in: path
      required: true
      schema: { type: string, example: "JKT-01" }
    OrderId:
      name: id
      in: path
      required: true
      schema: { type: integer }
    Page:
      name: page
      in: query
      schema: { type: integer, default: 1 }
    PerPage:
      name: per_page
      in: query
      schema: { type: integer, default: 20, maximum: 100 }

  responses:
    BadRequest:
      description: 请求参数错误
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: 未认证
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: 无权限
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: 资源不存在
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: 请求频率过高
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: "INVALID_OTP" }
            message: { type: string, example: "Kode OTP tidak valid" }

    User:
      type: object
      properties:
        id: { type: integer }
        phone: { type: string }
        name: { type: string }
        avatar_url: { type: string }
        role_flags: { type: object }
        tier_id: { type: string }
        lang: { type: string }

    Store:
      type: object
      properties:
        id: { type: string, example: "JKT-01" }
        name: { type: string }
        grade: { type: string, enum: [flagship, depot, soon] }
        franchisee: { type: string }
        km: { type: number }
        open_hours: { type: string }
        tk_count: { type: integer }
        radius_km: { type: number }
        rate_avg: { type: number }
        pickup_ok: { type: boolean }

    ProductWithStock:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        icon: { type: string }
        price: { type: integer, description: 零售价(分) }
        was_price: { type: integer }
        unit: { type: string }
        breaks: { type: array }
        stock_qty: { type: integer }
        is_heavy: { type: boolean }
        rate_avg: { type: number }

    ProductDetail:
      allOf:
        - $ref: '#/components/schemas/ProductWithStock'
        - type: object
          properties:
            install: { type: object, nullable: true }
            image_urls: { type: array, items: { type: string } }
            sold_count: { type: integer }

    PriceResult:
      type: object
      properties:
        retail: { type: integer, description: 零售价(分) }
        unit: { type: integer, description: 成交单价(分) }
        tier_off: { type: number, description: 等级折扣% }
        vol_off: { type: number, description: 阶梯量折扣% }
        total_off: { type: number, description: 总折扣% }
        save: { type: integer, description: 节省金额(分) }
        line_total: { type: integer, description: 行总价(分) }
        tier: { type: object }
        next_break: { type: object, nullable: true, description: 下一个阶梯量折扣 }

    CartStoreGroup:
      type: object
      properties:
        store_id: { type: string }
        store_name: { type: string }
        lines:
          type: array
          items:
            type: object
            properties:
              line_id: { type: integer }
              product_id: { type: string }
              name: { type: string }
              qty: { type: integer }
              unit_price: { type: integer }
              line_total: { type: integer }
              child_lines:
                type: array
                description: 安装服务子行
                items: { type: object }
        subtotal: { type: integer }

    Order:
      type: object
      properties:
        id: { type: integer }
        order_no: { type: string }
        type: { type: string, enum: [goods, service] }
        status: { type: string }
        total_amount: { type: integer }
        store_id: { type: string }
        created_at: { type: string, format: date-time }

    OrderDetail:
      allOf:
        - $ref: '#/components/schemas/Order'
        - type: object
          properties:
            lines: { type: array }
            delivery_mode: { type: string }
            tukang: { type: object, nullable: true }
            payment: { type: object }
            timeline: { type: array }

    Pagination:
      type: object
      properties:
        total: { type: integer }
        page: { type: integer }
        per_page: { type: integer }
        last_page: { type: integer }
