openapi: "3.0.3"
info:
  title: D-Watch API
  description: |
    D-Watch(DFAロボット監視システム)の画面(Next.js)が、同一アプリ内の API ルート
    (`app/api/...`)を呼び出すためのAPI定義(D-001)。バックエンドは外部サーバーではなく、
    画面とAPIは同一Next.jsアプリ・同一リポジトリの中にある。API ルート(Route Handler)が
    Prisma経由でDBを直接読む(D-046)。

    ## 前提
    - 認証: cookie方式(`dwatch_session`)。共有トークンを /login で入力して発行する。
      P1はDFA社内利用のみ、IP制限併用(D-072 / D-077項5 / D-079項1)。
      認証とIP制限は `proxy.ts` が全ルートで判定する。未認証は401、IP拒否は403。
    - 日時はISO 8601(JST, +09:00)。日付のみの場合は YYYY-MM-DD。
    - 一覧系は page / per_page でページネーション(デフォルト per_page=50)。
    - エラーレスポンスは全エンドポイント共通で Error スキーマ。
    - grade(◎○△×)、escalation_level(L1〜L3)、接続状態などの判定はすべてサーバ側で算出し、
      フロントは表示のみ行う。判定閾値(90%/70%/10%、L2=2日/L3=3日)はサーバ設定値。

    ## データソースに関する注記
    - タグ [inspections] のエンドポイント群は TeamWorks が構築するメインDBとは別の
      DFA独自管理データストアで実装する。他のエンドポイントとは実装基盤が異なる。
    - GET /clean-logs/{id}/areas は TeamWorks 第8回 A9-2(2026-09-11 受領)で
      「1エリア(=1ロボット)につき1つの完了率」が取得可能と確定したため本実装する。
      データ元は clean_task_reports(2026-09-30 に clean_status_histories から切り替えた。
      指示書AQ 決定2・決定3。clean_status_histories はもう読まない)。
      ただし1回の清掃の中を区域別に分けた内訳(確認事項#10 の本来の問い)は依然として未解決で、
      本エンドポイントはそれを解決するものではない。
    - レポート系は前月分をサマリテーブル(customer_summaries/facility_summaries)から、
      当月分を詳細テーブルからオンザフライ集計する。この切替はAPI内部で吸収する。
  version: "0.1.0"

servers:
  - url: /api

security:
  - cookieAuth: []

tags:
  - name: masters
    description: フィルタ用マスタ(グループ/顧客)
  - name: dashboard
    description: ダッシュボード
  - name: clean-logs
    description: 清掃詳細ログ
  - name: prechecks
    description: アラート(事前チェック)
  - name: errors
    description: エラー一覧(robot_error_reports)
  - name: reports
    description: レポート(KPI/施設別パフォーマンス/連続問題施設/エラー種別)
  - name: clean-area
    description: 清掃面積(ホールディングス/顧客/店舗 × 期間の合計)
  - name: ds-devices
    description: D-Secログ(D-S制御盤稼働状況)
  - name: ds-alerts
    description: D-Secアラート
  - name: facilities
    description: 施設ロボットマスタ(Viewer)
  - name: schedules
    description: スケジュール(Viewer)
  - name: inspections
    description: 点検記録 — ※TeamWorks構築のメインDBとは別のDFA独自管理データストアで実装
  - name: auth
    description: 認証(簡易トークン + cookie)

paths:

  # ------------------------------------------------------------------- auth
  /auth/login:
    post:
      tags: [auth]
      summary: 共有トークンを検証して `dwatch_session` cookie を発行する
      description: |
        認証不要(`security: []`)。ここを保護すると誰もログインできない。
        IP制限は proxy が別途かけるので、許可されないIPからは403になる。
        失敗時は「トークンが違う」以上の情報を返さない。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  description: 共有トークン。★値をログに出さないこと★
      responses:
        "204":
          description: |
            成功。`Set-Cookie: dwatch_session=...; HttpOnly; Secure; SameSite=Lax; Path=/`
            を返す。★Expires / Max-Age は付けない(CLAUDE.md §7 でトークン期限は禁止)★
          headers:
            Set-Cookie:
              schema: { type: string }
        "401":
          description: トークンが一致しない
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: invalid_token
                  message: トークンが違います
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /auth/logout:
    post:
      tags: [auth]
      summary: "`dwatch_session` cookie を削除する"
      responses:
        "204":
          description: 成功。cookie を削除する Set-Cookie を返す
          headers:
            Set-Cookie:
              schema: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ---------------------------------------------------------------- masters
  /groups:
    get:
      tags: [masters]
      summary: ホールディングス一覧(セレクトリスト用)
      description: |
        画面の見出し語は「ホールディングス」(2026-09-15 ユーザー指示)。
        テーブル名・キー名は DB の物理名のまま `groups` / `group_name` を使う
        (CLAUDE.md §9.2「DBカラムは勝手にリネームしない」)。見出し語は `lib/i18n.ts` で当てる。

        ## meta を返し、ページングはしない(指示書AK §4-1 #1)
        page / per_page は受け取らない。セレクトリスト用なので、既定の per_page=50 で
        切ると51件目以降の選択肢が黙って消えるため。
        meta 自体は返す(CLAUDE.md §9.3「一覧APIは必ず meta を返す」)。意味は
        /reports/facility-performance と同じ「page を持たない一覧」の書き方に合わせる。
          - meta.total: 絞り込み後の全件数
          - meta.page: 常に 1
          - meta.per_page: そのページに入っている件数(= total。1ページに全件返すため)

        ## エラー応答を宣言していない理由(指示書AK §4-1 #2)
        このエンドポイントはクエリパラメータを持たないので 422 が起きない。
        401 / 403 / 500 は**全エンドポイントで未宣言**のまま揃えてあり、ここだけ足すと
        書式が不揃いになる(別課題として記録済み)。実装は CLAUDE.md §9.3 の
        `{ error: { code, message, details } }` を返す。

        ## 絞り込みと並び(指示書AK §4-1 #4 / #5)
        - `deleted_at IS NULL` の行だけを返す(GET /facilities と揃える)。
          `inactive` では絞らない — 無効化は削除ではなく、画面が見せ分ける可能性がある
        - 並び順は **id の昇順**(D-052 / D-055 と同じく契約に明記する)。
          名前順にしないのは、日本語の並びが DB の照合順序と JS の localeCompare で
          一致せず、データソースによって順序が変わりうるため

        ## group_id = 0 はこの一覧に現れない(指示書AK §4-1 #3 / §4-4)
        0 は `customers.group_id` の「ホールディングスなし」センチネル(D-011)であって、
        グループの実体ではない。`groups` テーブルに id=0 の行は無く、API にも漏らさない
        (0 → null の変換は `lib/format/group-id.ts` の1箇所だけが知っている)。
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Group" }
                  meta: { $ref: "#/components/schemas/Meta" }

  /customers:
    get:
      tags: [masters]
      summary: 顧客一覧(セレクトリスト用)
      description: |
        画面の見出し語は「顧客」(第11回 P-15=B: `customers` は法人の単位。2026-10-05。D-158)。
        旧: 「屋号」(2026-09-15)。キー名は DB の物理名のまま
        `customers` / `customer_name` を使う。見出し語は `lib/i18n.ts` で当てる。

        meta / エラー応答 / deleted_at / 並び順の扱いは GET /groups と同じ。
        ただし本エンドポイントは group_id を受け取るので **422 を宣言する**
        (他の一覧APIも 422 は宣言済みで、足すほうが書式が揃う)。

        ## 該当0件は 200(空配列)
        実在しない group_id を指定されても 404 にしない。「該当なし」は正常な結果で、
        404 だと「エンドポイントが無い」と区別できなくなる。

        ## contract を返さない
        `customers.contract` は削除依頼済み(D-010)。契約フラグは `facilities.contract`
        のみが正で、顧客側には持たせない。
      parameters:
        - name: group_id
          in: query
          schema: { type: integer, minimum: 1 }
          description: |
            ホールディングスで絞り込み(完全一致)。省略時は全件。
            **1以上**のみ有効。0・負値・非整数・`1abc` のような前方一致・安全整数超は
            422 で弾く(PathId と同じ作法。D-123)。
            0 は「ホールディングスなし」のセンチネル(D-011)であって有効なIDではないため、
            **「ホールディングスなしの顧客だけを引く」手段は用意しない**(指示書AK §4-1 #3)。
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Customer" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # -------------------------------------------------------------- dashboard
  /dashboard:
    get:
      tags: [dashboard]
      summary: ダッシュボード(清掃結果サマリ/進捗/失敗内訳/セキュリティバナー)
      description: |
        ★date を省略すると(★空文字の ?date= も省略と同じ★)「直近モード」★(D-159)。
        summary と problem_logs は「各清掃エリアの直近の清掃」から作る —
        清掃エリアごとに、本清掃(precheck = 0)のうち
        ★予定の終了 + 落ち着き時間(30分) ≤ 現在★ かつ ★予定の開始 ≥ 現在 − 7日★ を満たすものの中で
        開始が一番新しい1件(境界は両方とも含む。同じ開始なら id の大きいほう)。
        ★終わった予定だけ★を見る。★出発失敗(レポート行が無い予定)も含み、未実施に数える★。
        7日以内に条件を満たす予定が無い清掃エリアは出さない。
        日数・分は lib/config の DASHBOARD_LATEST_RUN(lookbackDays / settleMinutes)が正。
        ★schedule_progress / rescue_required / security_banner は省略時も当日★(D-049 / D-159)。
        date を指定すると、summary と problem_logs もその日の清掃から作る(従来どおり)。
        ★コンパクト版清掃詳細ログは problem_logs で返す★(D-159。旧: GET /clean-logs を再利用)。
        別の API にすると2本のリクエストが別々に「現在」を取るので、
        境界(終了 + 30分ちょうど付近)で数値カードと表の母集団がずれうるため。
      parameters:
        - name: date
          in: query
          schema: { type: string, format: date }
          description: |
            対象日。指定するとその日の清掃で summary / problem_logs を作る。
            省略(空文字を含む)すると直近モード(各清掃エリアの直近の清掃。D-159)。
            schedule_progress / rescue_required は省略時は当日
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary:
                    type: object
                    description: |
                      ★母集団は date の有無で変わる★(D-159)。
                      date 省略(直近モード): 各清掃エリアの直近の清掃(7日以内・終わった予定のみ・出発失敗を含む。1エリア最大1行)。
                      date 指定: その日の清掃。
                      problem_logs と同じ母集団(rescue_required・schedule_progress とは違う)
                    properties:
                      success: { type: integer, example: 132 }
                      failed: { type: integer, example: 9 }
                      not_executed: { type: integer, example: 4 }
                      pending:
                        type: integer
                        example: 2
                        description: >-
                          ★結果待ち★(CleanLogItem.result_pending=true の行。ユーザー判断 2026-10-07。D-173)。
                          success / failed / not_executed には入れない
                      success_rate:
                        type: number
                        format: float
                        nullable: true
                        example: 91.03
                        description: >-
                          成功 / (成功+失敗+未実施) * 100。分母が0のときは null。
                          ★pending(結果待ち)は分母に入れない★(D-173)
                  schedule_progress:
                    type: object
                    properties:
                      planned:
                        type: integer
                        example: 145
                        description: スケジュールマスタの当日該当件数(active のみ)。清掃ログの行数ではない
                      completed:
                        type: integer
                        example: 141
                        description: 出発した件数(departed === true)。成功+失敗であり「完了した件数」ではない
                      rate:
                        type: number
                        format: float
                        nullable: true
                        example: 97.24
                        description: completed / planned * 100。planned が 0 のときは null
                  failure_breakdown:
                    type: array
                    description: |
                      発生件数の降順。
                      M2 では返さない(D-048)。M3 でエラーテーブルを扱うときに実装する
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          enum: [not_executed, security_active, control_error, robot_error]
                          description: 1:未実施 2:セキュリティ有効 3:D-S制御盤異常 4:清掃ロボット異常
                        label: { type: string, example: "セキュリティ有効" }
                        count: { type: integer, example: 4 }
                  security_banner:
                    type: array
                    description: セキュリティ無効のまま復帰していない施設(D-Security契約施設のみ)
                    items:
                      type: object
                      properties:
                        facility_name: { type: string, example: "バリューマート渋谷本店" }
                        section_name: { type: string, example: "1F売り場" }
                        since: { type: string, format: date-time, example: "2026-07-14T03:42:00+09:00" }
                  rescue_required:
                    type: array
                    description: |
                      要レスキュー店舗。指定日時点で3条件(未実施3日以上連続 / 連続問題レベル3 /
                      完了率が前回比で急落)のいずれかに当たる施設(ユーザー指示 2026-09-15)。
                      ★指定日に清掃予定が無い施設も含む★ — 各施設の「指定日以前で最も新しい
                      スケジュール存在日」の状態で判定する(evaluated_on)。
                      ★summary の数値カード(success/failed/not_executed/success_rate)には
                      影響しない★ — 予定の無い施設を分母に入れないため(ユーザー指示 2026-09-15)。
                      並びは reasons の件数の降順 → consecutive_problem_days の降順 →
                      facility_id の昇順(指示書AJ 段階1)。
                      ★理由の数を第1キーにした理由★: 連続日数だけで並べると、急落
                      (前日まで正常なので連続日数が浅いのが当たり前)が必ず下に沈む。
                      ★それでも「急落だけ」の施設は下のほうに来る★ — そこは件数の上限を
                      撤廃したことで補っている。
                      ★★件数の上限は無い(該当する施設を全部返す。ユーザー指示 2026-09-15
                      「上限は設けずに、表示は5件、もっと表示のようなのもので全件」)★★ —
                      上限で切ると、切られたことに運用担当が気づけない(実データで7件中2件が
                      黙って消えていた。指示書AJ §1)。要レスキューは施設ごとに最大1行なので
                      施設数が自然な上限になり、ページ送りは要らない。
                      初期表示を5件に絞るのは画面側の責務(RESCUE_INITIAL_VISIBLE)。
                      ★評価日(evaluated_on)の古さにも上限を設けない★(ユーザー指示
                      2026-09-15「これも上限無し」) — 清掃予定が止まった施設は遡り幅いっぱい
                      出続けるが、時間が経ったという理由だけで困っている店舗が一覧から
                      消えるほうが危ない。いつの状態かは evaluated_on で読み手が判断できる。
                      ★基準日は直近モードでも当日★(D-159) — summary / problem_logs とは母集団が違う。
                      ★既存の一覧API(GET /clean-logs)で代わりにならない★ —
                      要レスキューは CleanLogItem だけでは組み立てられない
                      (前回比も未実施の連続日数も入っていない)。problem_logs も同じ理由で本APIに載せている
                      (「各清掃エリアの直近1件」は既存の一覧APIでは表せない)。
                    items:
                      type: object
                      properties:
                        facility_id:   { type: integer, example: 7 }
                        facility_name: { type: string,  example: "メガバリューマート緑野店" }
                        evaluated_on:  { type: string, format: date, example: "2026-09-12" }
                        reasons:
                          type: array
                          description: |
                            当たった条件。★複数該当したら全部入る★。
                            文言はクライアント側で作る(D-111。EN切替で和文が残るのを避けるため)。
                          items:
                            type: string
                            enum: [not_executed_streak, escalation_l3, completion_drop]
                        not_executed_days:        { type: integer, example: 3 }
                        consecutive_problem_days: { type: integer, example: 4 }
                        completion_rate:
                          type: number
                          nullable: true
                          example: 8.5
                          description: |
                            評価日(evaluated_on)の施設の完了率(丸めない生値)。
                            ★その日の1回ごとの完了率(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。`task_area = 0` は 0)の単純平均(清掃1回＝1票)★。D-155。第11回 P-2・P-5 で確定。D-158。
                            ★未実施の清掃は分母に入れない★。全部未実施なら null。
                            ★2026-09-30 に面積加重(Σclean_area ÷ Σtask_area)から変更した★
                            (指示書AQ 決定4 / 追補 5-5。`lib/judge/kpi.ts` の `runCompletionRate()` / `meanCleanRatio()`)。
                        previous_completion_rate:
                          type: number
                          nullable: true
                          example: 73.4
                          description: |
                            前回のスケジュール存在日(カレンダー前日ではない)の完了率。
                            completion_rate と同じ式(1回ごとの完了率の単純平均)。
                        drop_ratio:
                          type: number
                          nullable: true
                          example: 0.884
                          description: |
                            (previous_completion_rate − completion_rate) ÷ previous_completion_rate。
                            どちらかが null、または前回が 0 のときは null。
                      required: [facility_id, facility_name, evaluated_on, reasons]
                  problem_logs:
                    type: array
                    description: |
                      ダッシュボードのコンパクト版清掃詳細ログ(D-159)。
                      ★summary と同じ母集団★のうち、判定(grade)が △ か × の行。
                      並びは scheduled_at の降順 → id の昇順(データソースの順のまま。並べ替え直さない)。
                      ★件数の上限は無い★ — 直近モードは1清掃エリア最大1行なので清掃エリア数が自然な上限になる。
                      上限で切ると切られたことに気づけない。画面が先頭5件を出す。
                      連続日数(consecutive_problem_days)は表示上限で頭打ちになる
                      (consecutive_problem_days_capped を参照。GET /clean-logs と同じ)。
                      ★0件は「問題の行が無い」という事実★なので省略しない。
                    items: { $ref: "#/components/schemas/CleanLogItem" }

  # -------------------------------------------------------------- clean-logs
  /clean-logs:
    get:
      tags: [clean-logs]
      summary: 清掃詳細ログ一覧(1行=1ロボット×1スケジュール実行)
      parameters:
        - name: date_from
          in: query
          schema: { type: string, format: date }
          description: 清掃開始日時の範囲(from)。省略時は直近1日
        - name: date_to
          in: query
          schema: { type: string, format: date }
          description: |
            清掃開始日時の範囲(to)。from-toは最大2年間(731日。うるう日を含みうるため730ではない)、
            超過は422(ユーザー指示 2026-09-15)。
            上限がかかるのは date_from と date_to の**両方**が指定されたときだけで、
            片方だけ・両方とも未指定のときは上限なし。
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: facility_name
          in: query
          schema: { type: string }
          description: |
            施設名(部分一致)。記号(中点・長音・空白・ハイフン類)と大文字小文字を無視して照合する
            (「ドンキホーテ」で「ドン・キホーテ戸塚」が引ける)。
            施設名だけ正規化が強いのは、店名の記号が表記ゆれで桁位置の意味を持たないため。
            シリアル・機種・住所の記号は桁や番地の区切りなので除かない(指示書AG D-2)。
        - name: model
          in: query
          schema: { type: string }
          description: 機種(部分一致・大文字小文字を区別しない)。記号は除かない。
        - name: serial
          in: query
          schema: { type: string }
          description: |
            シリアル番号(部分一致・大文字小文字を区別しない)。記号は除かない
            (除くと「PUDU-CC1-0001」が「10001」でヒットし、桁の境界が消えるため)。
        - name: result
          in: query
          schema:
            type: string
            enum: [problem, critical, all]
            default: all
          description: problem=△×のみ / critical=×のみ / all=全件
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/CleanLogItem" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /clean-logs/export:
    get:
      tags: [clean-logs]
      summary: 清掃詳細ログCSVエクスポート
      description: |
        /clean-logs と同一のフィルタパラメータを受け付ける(page/per_pageは無視)。

        絞り込んだ結果の行数が上限(MAX_CSV_ROWS = 20,000行)を超えるときは 422 を返す
        (ユーザー指示 2026-09-15)。
        **上限は日数ではなく行数**で決めている — 同じ2年でも1店舗なら約1,300行、
        全店舗なら約25万行で200倍違うため。日数で切ると軽い検索まで巻き添えで禁止することになる。
        上限は「含む」(ちょうど20,000行は出力できる)。
      parameters:
        - name: date_from
          in: query
          schema: { type: string, format: date }
        - name: date_to
          in: query
          schema: { type: string, format: date }
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: facility_name
          in: query
          schema: { type: string }
        - name: model
          in: query
          schema: { type: string }
        - name: serial
          in: query
          schema: { type: string }
        - name: result
          in: query
          schema: { type: string, enum: [problem, critical, all], default: all }
      responses:
        "200":
          description: |
            CSVファイル(UTF-8 BOM付き, ヘッダ行あり)。
            セルの先頭が数式として解釈されうる文字(= + - @ TAB CR)のときは
            ' を前置して文字列として出力する(CSVインジェクション対策。D-125)。
            完了率が無い行(未実施)の完了率は**空欄**(空のセル)にする(D-164)。
            以前は「-」を出しており、上の対策で「'-」になって Excel に見えていた。
          content:
            text/csv:
              schema: { type: string, format: binary }
        "422":
          description: |
            パラメータが不正(日付形式・範囲)、または**対象行数が上限を超えている**。
            行数超過のときの details は `{ total, max }` の**整数**で、
            バリデーションエラーの `{ パラメータ名: [理由] }` とは形が違う
            (「あと何件減らせばよいか」を文言からではなく機械可読に伝えるため)。
            画面側も一覧の meta.total を見て事前にCSVボタンを無効化するが、
            APIを直接叩く経路のためサーバ側の歯止めは残す。
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code: { type: string, example: "validation_failed" }
                      message:
                        type: string
                        example: "この期間はCSVで出力できません。対象が25000件で、上限の20000件を超えています。期間を短くするか、店舗で絞り込んでください。"
                      details:
                        type: object
                        nullable: true
                        properties:
                          total: { type: integer, example: 25000 }
                          max: { type: integer, example: 20000 }

  /clean-logs/{id}/advice:
    get:
      tags: [clean-logs]
      summary: 対応アドバイス(行展開時の推定原因+確認アクション)
      description: |
        該当したルールを **すべて** 返す(モックの挙動を踏襲)。単数ではない点に注意。

        Phase A:
          R1: 出発失敗(初回)→アラート確認 / R2: 完了率×(10%未満)→通路・ロボット確認 /
          R3: 2日以上連続→現場ヒアリング / R4: 3日以上連続→DFAオンサイト訪問検討
        Phase B(エラー種別):
          B1: 完了率10%以上70%未満→マップ更新の確認 / B2: ハンドルアップ検知 / B3: オフライン検知

        「連続」はカレンダー日ではなくスケジュール存在日の連続で数える(D-026)。
        文言(title / estimated_cause / recommended_actions)は運用設定で編集可能
        (GET/PATCH /settings/advice)。発火条件は仕様のため変更できない。
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  escalation_level:
                    type: string
                    enum: [L1, L2, L3]
                    nullable: true
                    example: L2
                  consecutive_problem_days:
                    type: integer
                    example: 2
                    description: >-
                      △以下または出発失敗の連続日数。表示上限で頭打ちになる
                      (consecutive_problem_days_capped を参照)
                  consecutive_problem_days_capped:
                    type: boolean
                    example: false
                    description: >-
                      consecutive_problem_days が表示上限(GET /settings/display-limits の
                      consecutive_days_max)で頭打ちになっているか。true のとき実際の連続日数は
                      この値より大きい(「{n}日以上」と表示する)。上限ちょうどのときは false。
                      escalation_level は上限をかける前の生値で判定する(上限は表示のためだけ)
                  max_level:
                    type: integer
                    example: 3
                    description: 該当ルールの最大レベル(行バッジの色分け用)。該当なしは0
                  advices:
                    type: array
                    description: 該当したルール。該当なしは空配列
                    items: { $ref: "#/components/schemas/AdviceItem" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /clean-logs/{id}/areas:
    get:
      tags: [clean-logs]
      summary: エリア別完了率(施設×日付。棒グラフ用)
      description: |
        その清掃ログと**同じ施設・同じ日**の全エリア(=清掃ロボット)の完了率を返す。
        返す集合にはその清掃ログ自身のエリアも含む。

        - `{id}` はエリアを特定するキーではなく、**施設と日付を特定するための入口**である。
          画面(F-02 清掃詳細ログ)の行から開くモーダルなので、行のidを入口にしている。
        - 出所: TeamWorks 第8回 A9-2(2026-09-11 受領)。
          「エリア毎の清掃完了率は取得可能。正確には清掃ロボット毎の清掃率だが、
          清掃ロボットはエリアに紐づく」= **1エリア(=1ロボット)につき1つの完了率**。
          データ元は `clean_task_reports`(2026-09-30 に `clean_status_histories` から切り替えた。
          指示書AQ 決定2・決定3)。
        - ★1回の清掃の中を売り場・通路・レジ前に区切った**区域別の内訳**は依然として取得不可★
          (確認事項#10 の本来の問いは未解決。第9回で聞き直し中)。
        - `items[].completion_rate` は 1回ごとの完了率(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。`task_area = 0` は 0)(D-155。第11回 P-2・P-5 で確定。D-158)、
          `overall_completion_rate` は ★その単純平均(清掃1回＝1票)★。
          ★未実施(`completion_rate: null`)のエリアは平均の分母に入れない★。
          ★2026-09-30 に面積ベース(Σclean_area ÷ Σtask_area)から `clean_ratio` 列の単純平均へ変更し、
          2026-10-02 に1回ごとの完了率(`runCompletionRate()`)の単純平均へ変更した(D-155。第11回 P-2・P-5 で確定。D-158)★
          (指示書AQ 決定4 / 追補 5-5「完了率の定義は全画面で1つ」、指示書AS)。
          以前の「行と全体で出し方が違う(意図した非対称)」は解消した。
        - 並び順は完了率の昇順。**未実施(null)は先頭**。同値は `section_id` 昇順。
        - `overall_*` と `total_count` / `problem_count` は `filter` に関係なく
          **全エリアから**計算する(「問題エリアのみ」を選んでも全体率は変わらない)。
      parameters:
        - $ref: "#/components/parameters/PathId"
        - name: filter
          in: query
          schema:
            type: string
            enum: [problem, all]
            default: problem
          description: problem=△×のエリアのみ / all=全件
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [overall_completion_rate, overall_grade, total_count, problem_count, items]
                properties:
                  overall_completion_rate:
                    type: number
                    format: float
                    nullable: true
                    example: 63.2
                    description: |
                      その施設・その日の全エリアの完了率(丸めない生値)。
                      ★items[].completion_rate(= 1回ごとの完了率(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。`task_area = 0` は 0))の単純平均
                      (清掃1回＝1票)★。D-155。第11回 P-2・P-5 で確定。D-158。★未実施(null)のエリアは分母に入れない★。
                      ★Σclean_area ÷ Σtask_area(期間の合計どうし)ではない★(指示書AQ 決定4 / 追補 5-5)。
                      面積で加重もしない。
                      畳み方はレポートKPI・GET /clean-area と同じ(`lib/judge/kpi.ts` の `meanCleanRatio()`)。
                      全エリア未実施(平均を取れるエリアが0)のときは null。
                  overall_grade:
                    type: string
                    enum: ["◎", "○", "△", "×"]
                    example: "△"
                    description: overall_completion_rate を judgeGrade() にかけた判定(null は ×)
                  total_count:
                    type: integer
                    example: 3
                    description: その施設・その日の全エリア数(filter の前に数える)
                  problem_count:
                    type: integer
                    example: 2
                    description: 判定が △ または × のエリア数(filter の前に数える)
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/AreaCompletionItem" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # -------------------------------------------------------------- prechecks
  /prechecks:
    get:
      tags: [prechecks]
      summary: アラート(事前チェック)一覧+サマリ
      description: |
        並び順は detected_at の降順(新しい検知が上)固定。同値のときは id の昇順で安定させる。
        既定フィルタが status=open のため、毎朝いちばん新しい未解消・対応中から確認できる並びにしている。
        ソート指定のクエリパラメータは無い(固定ソート)。
      parameters:
        - name: date_from
          in: query
          schema: { type: string, format: date }
          description: 清掃開始予定日時の範囲(from)
        - name: date_to
          in: query
          schema: { type: string, format: date }
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: status
          in: query
          schema:
            type: string
            enum: [open, unresolved, in_progress, resolved, all]
            default: open
          description: open=未解消+対応中(resolution_status!==resolved)
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrecheckListResponse" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /prechecks/{id}/status:
    patch:
      tags: [prechecks]
      summary: 対応状況の更新(未解消→対応中→解消)
      description: |
        事前チェック由来のアラート(severity=precheck)の対応状況と備考を更新する(指示書AT)。

        **書き込むのは robot_error_reports の4列(status / comment / responded_at / resolved_at)だけ**
        (D-042 / CLAUDE.md §7)。updated_at は DB 側の ON UPDATE で更新される(アプリは書かない)。

        **日時の規則**(対応状況ごとに保たれる形):
        未解消 → responded_at・resolved_at とも null / 対応中 → responded_at のみ / 解消 → 両方あり。
        - 未解消 → 対応中: responded_at に現在時刻(既に値があれば上書きしない)
        - 未解消 → 解消: responded_at と resolved_at に現在時刻
        - 対応中 → 解消: resolved_at に現在時刻(responded_at は変えない。null なら現在時刻)
        - **戻すと消す**: 対応中 → 未解消 は responded_at を null に、解消 → 未解消 は両方を null に、
          解消 → 対応中 は resolved_at を null にする
        - 解消済みを再度「解消」にしても resolved_at を上書きしない

        **備考(note)の規則**: 省略すると変えない。空文字・空白だけは削除(note は null)。
        それ以外は前後の空白を除いて保存する。文字数はトリム前で数え、400文字を超えると422。

        何も変わらない要求(現在と同じ status で note を省略・同じ内容)は書き込まずに現在の値を返す。
        同時に2人が操作したときは後勝ち(行ロックはしない)。
      parameters:
        # PathId(type: integer)は参照しない。PrecheckItem.idはstring(D-070 1-2 / D-105)。
        # PathIdは他6箇所から参照されているため、ここだけインラインでstringを書く
        - name: id
          in: path
          required: true
          schema: { type: string, maxLength: 50 }
          description: |
            PrecheckItem.id。更新できるのは事前チェック由来(robot_error_reports.error_id。varchar(50))だけ。
            空文字・50文字を超えると422。文字種は絞らない
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [unresolved, in_progress, resolved]
                # ★リクエストも note(レスポンスの PrecheckItem.note と同じ名前)。旧 memo から改名(指示書AT §3-5)★
                note:
                  type: string
                  maxLength: 400
                  description: 備考(robot_error_reports.comment)。省略すると変えない。空文字・空白だけは削除
      responses:
        "200":
          description: 更新後の項目を返す(何も変わらない要求のときは現在の値)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrecheckItem" }
        "404":
          description: |
            対応状況を記録できるアラートが見つからない。次のどれかに当たる:
            robot_error_reports に error_id の行が無い / その行の clean_timetable_id が null /
            その行が事前チェック行ではなく本清掃行を指している。
            **出発失敗(launch_failed)の id(本清掃の clean_timetables.id)もここ**(記録先の行が無い。D-104 / D-105)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # -------------------------------------------------------------- errors
  /errors:
    get:
      tags: [errors]
      summary: エラー一覧(robot_error_reports の未対応エラー)
      description: |
        robot_error_reports の行を一覧する(D-160 / 指示書AW)。
        **対象は robot_error_reports の全行**(事前チェック・本清掃・予定なし・制御盤・セキュリティ・清掃開始失敗を問わない。
        robot_id が NULL の行も含む)。
        既定は対応状況が未解消・対応中の行だけ。include_resolved=true で解消も含める。

        **並び順**は detected_at(= errored_at、無ければ created_at)の降順、同値は id(error_id)の昇順(文字列の辞書順)固定。
        ソート指定のクエリパラメータは無い。

        **error_type・error_level・serial_number は DB の値のまま**返す(区分を作らない・変換しない。D-077 / D-160 項3)。
        制御盤のエラーは serial_number が制御盤のシリアル、セキュリティのエラーと清掃開始失敗は "0" になる(D-158)。

        GET /prechecks(アラート画面)との違い: あちらは事前チェック行に紐づくエラーと出発失敗だけを出す。
        こちらは robot_error_reports の行を予定の種類に関係なく出す(出発失敗は行が無いので出ない)。
        サマリ(summary)は無い。
      parameters:
        - name: date_from
          in: query
          schema: { type: string, format: date }
          description: |
            検知時刻(detected_at)の日付の範囲(from。JST)。errored_at が NULL の行は created_at の日付で絞る。
            date_from が date_to より後なら422。日数の上限は無い
        - name: date_to
          in: query
          schema: { type: string, format: date }
          description: 検知時刻の日付の範囲(to。その日を含む)
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: facility_name
          in: query
          schema: { type: string }
          description: |
            施設名(部分一致)。記号(中点・長音・空白・ハイフン類)と大文字小文字を無視して照合する(GET /clean-logs と同じ)。
            group_id / customer_id と同時に指定したら AND
        - name: error_type
          in: query
          schema: { type: string }
          description: 種類(error_type)の部分一致。大文字小文字を区別しない。記号は除かない。空・空白だけは絞らない
        - name: include_resolved
          in: query
          schema: { type: boolean, default: false }
          description: true=解消済みも含める / false(既定)=未解消・対応中だけ。小文字の true / false 以外は422
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorReportListResponse" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /errors/{error_id}/status:
    patch:
      tags: [errors]
      summary: エラーの対応状況の更新(未解消→対応中→解消)
      description: |
        robot_error_reports の1行の対応状況と備考を更新する(D-160 / 指示書AW)。
        **robot_error_reports に error_id の行があれば書ける(事前チェックかどうかを問わない)。無ければ404**。
        それ以外の規則は PATCH /prechecks/{id}/status と同じ。

        **書き込むのは robot_error_reports の4列(status / comment / responded_at / resolved_at)だけ**
        (D-042 / CLAUDE.md §7)。updated_at は DB 側の ON UPDATE で更新される(アプリは書かない)。

        **日時の規則**(対応状況ごとに保たれる形):
        未解消 → responded_at・resolved_at とも null / 対応中 → responded_at のみ / 解消 → 両方あり。
        - 未解消 → 対応中: responded_at に現在時刻(既に値があれば上書きしない)
        - 未解消 → 解消: responded_at と resolved_at に現在時刻
        - 対応中 → 解消: resolved_at に現在時刻(responded_at は変えない。null なら現在時刻)
        - **戻すと消す**: 対応中 → 未解消 は responded_at を null に、解消 → 未解消 は両方を null に、
          解消 → 対応中 は resolved_at を null にする
        - 解消済みを再度「解消」にしても resolved_at を上書きしない

        **備考(note)の規則**: 省略すると変えない。空文字・空白だけは削除(note は null)。
        それ以外は前後の空白を除いて保存する。文字数はトリム前で数え、400文字を超えると422。

        何も変わらない要求(現在と同じ status で note を省略・同じ内容)は書き込まずに現在の値を返す。
        同時に2人が操作したときは後勝ち(行ロックはしない)。
      parameters:
        # PathId(type: integer)は参照しない。error_id は varchar(50)(D-105)。/prechecks/{id}/status と同じ理由でインラインに書く
        - name: error_id
          in: path
          required: true
          schema: { type: string, maxLength: 50 }
          description: |
            ErrorReportItem.id(robot_error_reports.error_id。varchar(50))。
            空文字・50文字を超えると422。文字種は絞らない。大文字小文字は区別する(違えば404)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [unresolved, in_progress, resolved]
                note:
                  type: string
                  maxLength: 400
                  description: 備考(robot_error_reports.comment)。省略すると変えない。空文字・空白だけは削除
      responses:
        "200":
          description: 更新後の項目を返す(何も変わらない要求のときは現在の値)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorReportItem" }
        "404":
          description: robot_error_reports に error_id の行が無い(大文字小文字だけ違う id も含む)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ---------------------------------------------------------------- reports
  /reports/kpi:
    get:
      tags: [reports]
      summary: KPI4列+前期比(稼働率/出発成功率/清掃完了率/エラー発生率)
      description: |
        確定期間のKPIは顧客サマリ(customer_summaries)由来のため、
        **対象顧客が1社に定まるときだけ返す**(D-133 の安全弁の置き換え。指示書AK §2-1)。

        対象顧客の決め方
          - customer_id 指定       … その顧客(存在確認はしない。該当が無ければ 200・空)
          - group_id 指定          … 傘下の顧客がちょうど1社ならその顧客。それ以外は 422
          - どちらも未指定         … 顧客がちょうど1社ならその顧客。2社以上は 422

        **複数顧客の合算はしない** — 稼働率の分子(正常終了の回数)と清掃完了率の分子・分母(1回ごとの完了率の合計・件数)に当たる列が
        サマリに無く、率の平均を取るのは誤りになるため。
        **候補が2つ以上のときに暗黙で1社を選ぶこともしない**(選び間違いに気づけないため)。
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/Year"
        - $ref: "#/components/parameters/Month"
        - $ref: "#/components/parameters/Week"
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
      responses:
        "200":
          description: 前期(週次なら前週、月次なら前月)との比較値を含む
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportKpi" }
        "422":
          description: |
            パラメータが不正(period/year/month/week/group_id/customer_id)、
            または**対象顧客が1社に定まらない**。
            後者の details は `{ customer_ids: [...] }` の**整数の配列**で、
            /clean-logs/export の `{ total, max }` と同じく
            「画面が文言を読まずに次の操作を出せる」ようにするための例外(指示書AK §2-1)。
            customer_ids には候補(group_id 指定時はその傘下だけ、未指定なら全顧客)が入る。
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /reports/facility-performance:
    get:
      tags: [reports]
      summary: 施設別パフォーマンス(稼働率ワースト順)
      description: |
        並び順は稼働率の昇順(ワースト順)固定。同値のときは facility_id の昇順で安定させる
        (D-052と同じ規則)。稼働率が null の行は末尾に置く。

        ## meta の意味(page/per_page パラメータを持たない一覧のため)
        本エンドポイントは page / per_page を受け取らず、代わりに limit で上位N件を切り出す。
        レスポンスの meta は次の意味で返す。
          - meta.total: 絞り込み(status/facility_name/serial)適用後・limit 適用前の全件数。
            画面の「全N施設を表示」の N がこれ
          - meta.page: 常に 1
          - meta.per_page: 実際に適用された limit の値
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/Year"
        - $ref: "#/components/parameters/Month"
        - $ref: "#/components/parameters/Week"
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: facility_name
          in: query
          schema: { type: string }
          description: |
            施設名(部分一致)。記号(中点・長音・空白・ハイフン類)と大文字小文字を無視して照合する。
            施設名だけ正規化が強いのは、店名の記号が表記ゆれで桁位置の意味を持たないため。
            シリアルの記号は桁の区切りなので除かない(指示書AG D-2)。
        - name: serial
          in: query
          schema: { type: string }
          description: シリアル番号(部分一致・大文字小文字を区別しない)。記号は除かない。
        - name: status
          in: query
          schema:
            type: string
            enum: [good, warning, bad, no_data, all]
            default: all
          description: 良好/注意/要改善/判定なしで絞り込み
        - name: limit
          in: query
          schema: { type: integer, default: 10 }
          description: デフォルト上位10件。「他X件を表示」で limit を増やして再取得
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/FacilityPerformance" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /reports/facility-performance/export:
    get:
      tags: [reports]
      summary: 施設別パフォーマンスCSVエクスポート
      description: |
        /reports/facility-performance と同一のパラメータを受け付ける(**limit は無視**)(D-163)。
        絞り込み(group_id/customer_id/facility_name/serial/status)を適用した**全施設**を出力する
        (画面の「全施設表示」の上限に縛られない)。
        取得・絞り込み・並べ替え・連続日数の上限処理は一覧と同じ処理を通すので、
        並び順も一覧と同じ(稼働率の昇順・同値は facility_id の昇順・null は末尾)。

        絞り込んだ結果の行数が上限(MAX_CSV_ROWS = 20,000行。/clean-logs/export と共通)を超えるときは 422 を返す。
        施設数なので実際にはまず起きないが、歯止めは /clean-logs/export と揃える。
        上限は「含む」(ちょうど20,000行は出力できる)。
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/Year"
        - $ref: "#/components/parameters/Month"
        - $ref: "#/components/parameters/Week"
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: facility_name
          in: query
          schema: { type: string }
          description: /reports/facility-performance の facility_name と同じ(記号・大文字小文字を無視した部分一致)
        - name: serial
          in: query
          schema: { type: string }
          description: /reports/facility-performance の serial と同じ(部分一致・大文字小文字を区別しない)
        - name: status
          in: query
          schema:
            type: string
            enum: [good, warning, bad, no_data, all]
            default: all
          description: 良好/注意/要改善/判定なしで絞り込み
      responses:
        "200":
          description: |
            CSVファイル(UTF-8 BOM付き, CRLF, ヘッダ行あり)。**画面の言語によらず日本語**で出力する。
            セルの先頭が数式として解釈されうる文字(= + - @ TAB CR)のときは
            ' を前置して文字列として出力する(CSVインジェクション対策。D-125)。
            値が無い率は**空欄**(空のセル)にする(/clean-logs/export と同じ。D-164)。
            以前は「-」を出しており、上の対策で「'-」になって Excel に見えていた。

            列(この順。画面の表の9列に、施設名の直後のシリアルを足したもの):
              1. 施設名
              2. シリアル
              3. 稼働率
              4. 稼働率(前週/前月/前年) … 見出しは period による
              5. 出発成功率
              6. 完了率
              7. 完了率(前週/前月/前年)
              8. エラー率
              9. 変化 … 画面と同じ文字(→ / ▲n / ▼n)
              10. ステータス … 良好 / 注意 / 要改善 / 判定なし
            率は小数第1位を四捨五入した整数に % を付ける(値が無いときは空欄。D-164)。
            丸めは表示のみで、ステータスは API の status をそのまま表示名にする(CLAUDE.md §4.3)。

            Content-Disposition のファイル名(ASCII のみ。月は2桁):
              - weekly  … facility-performance_weekly_2026-10-w1.csv
              - monthly … facility-performance_monthly_2026-09.csv
              - yearly  … facility-performance_yearly_2025.csv
          content:
            text/csv:
              schema: { type: string, format: binary }
        "422":
          description: |
            パラメータが不正(period/year/month/week/group_id/customer_id/status。一覧と同じ)、
            または**対象施設数が上限を超えている**。
            行数超過のときの details は `{ total, max }` の**整数**で、
            バリデーションエラーの `{ パラメータ名: [理由] }` とは形が違う(/clean-logs/export と同じ)。
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code: { type: string, example: "validation_failed" }
                      message:
                        type: string
                        example: "CSVで出力できません。対象の施設が20001件で、上限の20000件を超えています。分類・施設名・ステータスで絞り込んでください。"
                      details:
                        type: object
                        nullable: true
                        properties:
                          total: { type: integer, example: 20001 }
                          max: { type: integer, example: 20000 }

  /reports/continuous-problems:
    get:
      tags: [reports]
      summary: 連続問題施設(2日以上連続△以下 or 出発失敗)
      description: |
        絞り込み(consecutive_problem_days >= 2)はサーバ側で行う。L1(初回)は含めない。
        並び順は連続日数の降順。同値のときは facility_id の昇順で安定させる(D-052と同じ規則)。
        「連続」はカレンダー日ではなくスケジュール存在日の連続で数える(D-026)。

        ## meta の意味(page/per_page パラメータを持たない一覧のため)
        本エンドポイントは page / per_page を受け取らず、代わりに limit で上位N件を切り出す。
        レスポンスの meta は次の意味で返す。
          - meta.total: 2日以上の絞り込み適用後・limit 適用前の全件数。
            画面の「他N件を表示」の算出元がこれ
          - meta.page: 常に 1
          - meta.per_page: 実際に適用された limit の値
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/Year"
        - $ref: "#/components/parameters/Month"
        - $ref: "#/components/parameters/Week"
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: limit
          in: query
          schema: { type: integer, default: 10 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/ContinuousProblem" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /reports/error-breakdown:
    get:
      tags: [reports]
      summary: エラー種別の内訳(発生件数の降順)
      description: |
        エラー種別(robot_error_reports.error_type の生値)ごとの発生件数の内訳。
        サーバ側で分類・変換しない(D-077)。件数は0〜N件で、種別数は固定されない。

        ## meta を返さない(一覧APIの例外。D-088で再判断)
        CLAUDE.md 9.3 は「一覧APIは必ず meta を返す」としているが、本エンドポイントは
        **例外として meta を返さない**。理由: エラー種別の内訳は集計結果であり全件返す。
        ページングが要るほどの種別数にはならない見込み(limit パラメータも無い)。
        レスポンスは { items } のみ。
        ★実データで種別が数十に膨らむ可能性がある。実DB接続後に件数を実測し、
        必要なら meta を返す形に再判断すること(D-088)★。
      parameters:
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/Year"
        - $ref: "#/components/parameters/Month"
        - $ref: "#/components/parameters/Week"
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/ErrorBreakdownItem" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ------------------------------------------------------------ clean-area
  /clean-area:
    get:
      tags: [clean-area]
      summary: 清掃面積の集計(ホールディングス/顧客/店舗 × 期間)
      description: |
        指定期間の清掃面積を、指定した粒度で集計して返す。
        **全体合計(`total`)と内訳(`items`)の両方**を返す。

        ## 集計元は clean_task_reports(指示書AQ 決定2・決定3。旧: clean_status_histories / 指示書AN §1-2)
        ★★面積と、実施回数・完了率で母集団が違う★★(D-155 項3 / 指示書AS D9。TeamWorks のサマリと同じ。第10回 N-3):
        - 面積(`clean_area` / `task_area`): ★予定外のタスク(`clean_timetable_id` が NULL)を含む全行★の合計。
          ★総清掃面積の表示に使う★(指示書AQ §1-3)。
        - 実施回数(`executed_count`)・完了率(`completion_rate`): ★`clean_timetable_id` が NULL でなく、
          その予定が本清掃(`precheck = 0`)である行だけ★。予定外・事前チェック・予定が見つからない行は数えない。
          そのため ★面積はあるが実施回数0・完了率 null の行が出うる★(予定外の清掃だけの施設)。
        - 完了率: ★1回ごとの完了率(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。
          `task_area = 0` は 0)の単純平均(清掃1回＝1票)★(D-155。第11回 P-2・P-5 で確定。D-158)。
          ★Σclean_area ÷ Σtask_area(期間の合計どうし)ではない★。★`clean_task_reports.clean_ratio` 列は使わない★
          (TeamWorks 回答「列の計算式が間違っている」。第10回 N-3)。面積で加重もしない。
          畳み方はレポート画面の当期集計と同じ(`lib/judge/kpi.ts` の `runCompletionRate()` / `meanCleanRatio()`。
          同じ「清掃完了率」を画面ごとに別の式にしないため。2箇所で別々に畳まない)。

        **サマリテーブル(`facility_summaries` / `customer_summaries`)の
        `task_area` / `clean_area` は読まない** — 実データで `task_area` が全24行 1000 ちょうど、
        `clean_area` も期間の違う行で同じ値になっており、**期間内の合計になっていない**
        (TeamWorks 第9回 Q2-1 で照会中)。
        D-102(「サマリはサマリテーブルを読む。履歴から再計算しない」)の対象は KPI 4指標であり、
        面積はそもそも画面に出していなかったので対象外と読む(指示書AN §1-2)。

        ★帰結: レポート画面(`/reports/kpi`)の完了率と、本エンドポイントの `completion_rate` は
        数字が食い違いうる★ — ★式は揃えた(追補 5-1)が経路が違う★ため(レポートは確定期間を
        サマリから読む。こちらは常に `clean_task_reports` から集計する)。
        どちらかが壊れているわけではない。

        `group_summaries` は存在しないため、ホールディングス単位は
        facilities → customers → groups のロールアップでアプリ側が作る。

        ## 期間(指示書AN §2-2 / §2-3)
        `date_from` / `date_to` は自由な日付範囲。週・月の粒度は持たない
        (サマリを読まないので月跨ぎ週の帰属ルール(D-071)に乗る必要がない)。
        **日数の上限は設けない** — 1本の集計クエリで、返るのは粒度ごとの行数(最大でも施設数)
        なので、期間を広げても応答の行数は増えない(/clean-logs の731日制限とはここが違う)。

        **省略時は全期間**。開いた瞬間が「累計」になり、期間を入れれば「指定期間の合計」になる。
        ただし**データの保持は2年**なので、実際には**直近2年の合計**(`docs/spec.md`)。
        ★画面にはその旨を明記すること★ — 書かないと3年目に数字が減ったときに問い合わせになる。

        絞る対象は `clean_task_reports.started_at`(実績)であって
        `clean_timetables.start_at`(予定)ではない。未実施の清掃はレポート行が無く、面積も持たない。

        ## 単位(指示書AN §2-4)
        単位は **㎡**(ユーザー確定 2026-09-16「単位は㎡でよいです」)。
        ★ただし TeamWorks の確認は未取得★(第9回 5章で照会中)。これは DFA の判断である。

        **レスポンスに単位は含めない** — 面積は数値だけを返す。単位の文字列は
        `lib/config/index.ts` の `AREA_UNIT` 1箇所だけが持ち、画面が付ける。
        API が単位を返すと「1箇所に置く」という担保が崩れ、回答が違ったときに直す場所が散らばる。

        ## limit を持たない(全件返す)
        内訳は粒度ごとに最大1行(facility なら施設数、customer / group ならそれ以下)で、
        ページングが要るほどの行数にならない。初期表示を絞るのは画面側の責務
        (`CLEAN_AREA_INITIAL_VISIBLE` / `cleanAreaVisibility()`)。
        要レスキュー(D-137)と同じ作法。

        ## meta の意味(page / per_page パラメータを持たない一覧のため)
          - meta.total: **`items` の件数**(= その粒度での内訳の行数)。絞り込み後・切り詰め無し。
            ★清掃回数ではない★(清掃回数は `total.executed_count`)
          - meta.page: 常に 1
          - meta.per_page: 常に meta.total と同じ(1ページに全件返すため)

        ## 401 を宣言している(他の一覧APIとの違い)
        他のエンドポイントは 401 / 403 / 500 を未宣言のまま揃えてあるが(GET /groups の
        description)、本エンドポイントは新規なので宣言してある。既存分を書き換えるのは別課題。
      parameters:
        - name: date_from
          in: query
          schema: { type: string, format: date }
          description: |
            `clean_task_reports.started_at` の範囲(from)。JST の YYYY-MM-DD。
            **省略時・空文字は下限なし**。形式違いは422。
        - name: date_to
          in: query
          schema: { type: string, format: date }
          description: |
            範囲(to)。**その日を含む**。**省略時・空文字は上限なし**。
            **日数の上限は設けない**(上の description)。
            `date_from` より前の日付を指定したら422。
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: facility_name
          in: query
          schema: { type: string }
          description: |
            施設名(部分一致)。記号(中点・長音・空白・ハイフン類)と大文字小文字を無視して
            照合する(D-129 / 指示書AG D-2)。`group_id` / `customer_id` とは **AND**。
        - name: group_by
          in: query
          schema:
            type: string
            enum: [group, customer, facility]
            default: facility
          description: |
            集計の粒度。enum 以外は422。
            ★どの粒度でも `total` は変わらない★(内訳の分け方が変わるだけ)。
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { $ref: "#/components/schemas/CleanAreaTotal" }
                  items:
                    type: array
                    description: |
                      内訳。`clean_area` の降順 → 同値なら `id` の昇順(安定した順序)。
                      ★`id` が null の行(グループなし)は末尾★。
                    items: { $ref: "#/components/schemas/CleanAreaRow" }
                  meta: { $ref: "#/components/schemas/Meta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # -------------------------------------------------------------- settings
  /settings/advice:
    get:
      tags: [settings]
      summary: アドバイス文言の一覧(システム管理>運用設定)
      description: |
        既定値に運用設定での編集内容を重ねたものを返す。
        保存先はDFA側の設定ファイル(data/settings/advice.json)。メインDBには保存しない。
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/AdviceSetting" }
    patch:
      tags: [settings]
      summary: アドバイス文言の更新
      description: |
        title / text / actions のみ編集できる。
        level / icon / condition(発火条件)は仕様のため変更不可。
        reset=true を指定すると、そのルールの編集を取り消して既定値に戻す。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rule]
              properties:
                rule:
                  type: string
                  enum: [R1, R2, R3, R4, B1, B2, B3]
                title: { type: string, maxLength: 100 }
                text: { type: string, maxLength: 400, description: 推定原因 }
                actions:
                  type: array
                  maxItems: 10
                  items: { type: string, maxLength: 100 }
                reset: { type: boolean, description: trueなら既定値に戻す }
      responses:
        "200":
          description: OK
        "422": { $ref: "#/components/responses/ValidationError" }

  /settings/display-limits:
    get:
      tags: [settings]
      summary: 表示上限の一覧(システム管理>運用設定)
      description: |
        既定値に運用設定での編集内容を重ねたものを返す。
        保存先はDFA側の設定ファイル(data/settings/display-limits.json)。メインDBには保存しない。
        判定(◎○△×・L1〜L3)には使わない。表示のための上限のみを扱う。
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/DisplayLimitSetting" }
    patch:
      tags: [settings]
      summary: 表示上限の更新
      description: |
        reset=true を指定すると、そのキーの編集を取り消して既定値に戻す。
        value は整数のみ。文字列("30")・小数・NaN・Infinity は 422。
        範囲外(min 未満 / max 超過)も 422。min / max は GET のレスポンスに含まれる。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key:
                  type: string
                  enum: [consecutive_days_max]
                value: { type: integer, example: 30 }
                reset: { type: boolean, description: trueなら既定値に戻す }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DisplayLimitSetting" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # -------------------------------------------------------------- ds-devices
  /ds-devices:
    get:
      tags: [ds-devices]
      summary: D-Secログ(D-S制御盤稼働状況一覧+サマリ)
      description: |
        並び順は 異常→正常 の自動ソート(サーバ側)。
        同一状態のなかでは serial_number の昇順で安定させる。
        トークン期限/残日数は画面要件整理シートで不要と確定済みのため含めない。
      parameters:
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: q
          in: query
          schema: { type: string }
          description: |
            施設名 or D-S制御盤シリアル番号(部分一致)。
            施設名は記号(中点・長音・空白・ハイフン類)と大文字小文字を無視して照合し、
            シリアル番号は大文字小文字だけを無視する(記号は除かない)。
            施設名だけ正規化が強いのは、店名の記号が表記ゆれで桁位置の意味を持たないため。
            シリアルの記号は桁の区切りなので除かない(指示書AG D-2)。
        - name: status
          in: query
          schema:
            type: string
            enum: [all, error, ok]
            default: all
          description: error=異常 / ok=正常
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DsDeviceListResponse" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /ds-devices/{serial_number}/note:
    patch:
      tags: [ds-devices]
      summary: D-S制御盤の備考を保存する
      description: |
        運用担当が入力するメモ。DFA側の独自ストアに保存し、メインDBには書き込まない。
        空文字を送ると備考を削除する(レスポンスの note は null になる)。
      parameters:
        - name: serial_number
          in: path
          required: true
          schema: { type: string, maxLength: 100 }
          description: |
            D-S制御盤のシリアル番号。100文字を超えると422。
            上限はDB側の controls.serial_number が varchar(100) であることに合わせている。
            文字種は絞らない(実データの形式が未確定のため。D-123)。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note: { type: string, example: "定期点検実施済み" }
      responses:
        "200":
          description: 保存後の値を返す
          content:
            application/json:
              schema:
                type: object
                properties:
                  serial_number: { type: string, example: "DS-001" }
                  note: { type: string, nullable: true, example: "定期点検実施済み" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # -------------------------------------------------------------- ds-alerts
  /ds-alerts:
    get:
      tags: [ds-alerts]
      summary: D-Secアラート一覧+サマリ
      description: |
        トークン期限種別は不要と確定済みのため enum から除外している。
        並び順は detected_at の降順(新しい検知が上)固定。同値のときは id の昇順で安定させる(D-052と同じ規則)。
      parameters:
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - name: type
          in: query
          schema:
            type: string
            enum: [all, security_off, disconnected]
            default: all
          description: security_off=セキュリティ無効 / disconnected=通信断
        - name: status
          in: query
          schema:
            type: string
            enum: [open, all, done]
            default: open
          description: open=未対応・対応中 / done=対応済み
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DsAlertListResponse" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /ds-alerts/{id}/status:
    patch:
      tags: [ds-alerts]
      summary: 対応状況の更新(未対応→対応中→対応済み)
      description: 対応済みになると一覧(status=open)から除外される。
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [pending, in_progress, done]
                memo: { type: string, maxLength: 400 }
      responses:
        "200":
          description: 更新後の項目を返す
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DsAlert" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # -------------------------------------------------------------- facilities
  /facilities:
    get:
      tags: [facilities]
      summary: 施設ロボットマスタ(Viewer)
      description: |
        登録・更新はD-Security側画面で行うため、D-WatchはGETのみ。
        monitoring_enabled / d_security_enabled は確認事項#14の解決(facilitiesテーブルへの
        フラグ追加)が前提。
      parameters:
        - name: q
          in: query
          schema: { type: string }
          description: |
            施設名 or 都道府県 or 住所 or シリアル番号 or メーカー or 機種(部分一致・大文字小文字を区別しない)。
            画面の検索欄はこれを使う。個別パラメータ(facility_name/serial/address)は互いにAND、
            qは個別パラメータとAND(qの内部だけがOR)。

            照合の強さはフィールドによって違う(指示書AG D-2)。
              - 施設名・都道府県 … 記号(中点・長音・空白・ハイフン類)と大文字小文字を無視する
              - 住所・シリアル番号・メーカー・機種 … 大文字小文字だけを無視し、記号は除かない
            施設名だけ正規化が強いのは、店名の記号が表記ゆれで桁位置の意味を持たないため。
            住所のハイフンは番地の区切り(除くと 1-2-3 と 2-3-4 が「23」で両方ヒットする)、
            シリアルの記号は桁の区切り(除くと PUDU-CC1-0001 が「10001」でヒットする)なので、
            そちらは除かない。個別パラメータも q と1文字も違わない規則で照合する。
        - name: facility_name
          in: query
          schema: { type: string }
          description: 施設名(部分一致)。記号と大文字小文字を無視する(q の施設名と同じ規則)。
        - name: serial
          in: query
          schema: { type: string }
          description: シリアル番号(部分一致・大文字小文字を区別しない)。記号は除かない。
        - name: address
          in: query
          schema: { type: string }
          description: 住所(部分一致・大文字小文字を区別しない)。記号(番地のハイフン)は除かない。
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Facility" }
                  meta: { $ref: "#/components/schemas/Meta" }
        # ★422 はこのエンドポイント唯一の失敗経路(group_id / customer_id の形式)★
        #   q / facility_name / serial / address に不正値という概念は無く、
        #   page / per_page は丸めるだけ(指示書AK 段階2)。
        "422": { $ref: "#/components/responses/ValidationError" }

  # --------------------------------------------------------------- schedules
  /schedules:
    get:
      tags: [schedules]
      summary: スケジュール一覧(Viewer)
      description: 登録・更新はD-Security側画面で行うため、D-WatchはGETのみ。
      parameters:
        - name: q
          in: query
          schema: { type: string }
          description: |
            施設名 or シリアル番号(部分一致・大文字小文字を区別しない)。
            画面の検索欄はこれを使う。facility_name/serialは互いにAND、qはそれらとAND(qの内部だけがOR)。
            施設名は記号(中点・長音・空白・ハイフン類)も無視し、シリアル番号は記号を除かない。
            施設名だけ正規化が強いのは、店名の記号が表記ゆれで桁位置の意味を持たないため。
            シリアルの記号は桁の区切りなので除かない(指示書AG D-2)。
        - name: facility_name
          in: query
          schema: { type: string }
          description: 施設名(部分一致)。記号と大文字小文字を無視する(q の施設名と同じ規則)。
        - name: serial
          in: query
          schema: { type: string }
          description: シリアル番号(部分一致・大文字小文字を区別しない)。記号は除かない。
        - $ref: "#/components/parameters/GroupId"
        - $ref: "#/components/parameters/CustomerId"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Schedule" }
                  meta: { $ref: "#/components/schemas/Meta" }
        # ★422 はこのエンドポイント唯一の失敗経路(group_id / customer_id の形式)★
        #   q / facility_name / serial に不正値という概念は無く、
        #   page / per_page は丸めるだけ(指示書AK 段階2)。
        "422": { $ref: "#/components/responses/ValidationError" }

  # ------------------------------------------------------------- inspections
  /inspections:
    get:
      tags: [inspections]
      summary: 点検記録一覧+サマリ
      description: |
        ※本タグ配下はTeamWorks構築のメインDBとは別のDFA独自管理データストアで実装する。
      parameters:
        - name: date_from
          in: query
          schema: { type: string, format: date }
          description: 訪問日の範囲(from)
        - name: date_to
          in: query
          schema: { type: string, format: date }
        - $ref: "#/components/parameters/CustomerId"
        - name: facility_name
          in: query
          schema: { type: string }
          description: |
            施設名(部分一致)。★未実装★。実装時は他の5系統と同じ
            `lib/format/search-text.ts` の `includesFoldName` を通し、
            記号(中点・長音・空白・ハイフン類)と大文字小文字を無視して照合すること。
            施設名だけ正規化が強いのは、店名の記号が表記ゆれで桁位置の意味を持たないため
            (指示書AG D-2)。
        - name: q
          in: query
          schema: { type: string }
          description: |
            記入者名 or シリアル番号(部分一致)。★未実装★。実装時は `includesFoldPlain` を通し、
            大文字小文字だけを無視すること(記号は桁の区切りなので除かない)。
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary:
                    type: object
                    properties:
                      total: { type: integer, example: 7, description: 点検記録数 }
                      facilities: { type: integer, example: 7, description: 点検施設数(ユニーク) }
                      with_notes: { type: integer, example: 1, description: 申し送りありの件数 }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/InspectionListItem" }
                  meta: { $ref: "#/components/schemas/Meta" }
    post:
      tags: [inspections]
      summary: 点検記録の新規登録(写真アップロード込み)
      description: |
        multipart/form-data で基本情報と写真を同時送信する。
        写真のフィールド名は photos[{item_type}][{phase}] 形式
        (例: photos[brush][before], photos[dust_box][after])。
        1枚あたり最大10MB、jpeg/png。Before/After×7項目=最大14枚。
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [visited_on, facility_id, robot_id, inspector_name, inspector_company]
              properties:
                visited_on: { type: string, format: date, example: "2026-07-14" }
                facility_id: { type: integer, example: 5 }
                robot_id:
                  type: integer
                  example: 12
                  description: 施設選択後、GET /facilities のrobotsから選ばせる
                inspector_name: { type: string, maxLength: 100, example: "点検 太郎" }
                inspector_company: { type: string, maxLength: 100, example: "DFA Robotics" }
                notes: { type: string, maxLength: 2000 }
                photos:
                  type: object
                  description: photos[item_type][phase] = 画像ファイル
                  additionalProperties:
                    type: object
                    additionalProperties: { type: string, format: binary }
      responses:
        "201":
          description: 作成された点検記録
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InspectionDetail" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /inspections/{id}:
    get:
      tags: [inspections]
      summary: 点検記録の詳細(Before/After写真グリッド用)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: 写真URLは署名付き一時URL(有効期限あり)で返す
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InspectionDetail" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [inspections]
      summary: 点検記録の削除(論理削除、誤登録対応)
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "204": { description: 削除成功 }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

# =============================================================== components
components:

  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: dwatch_session
      description: |
        簡易トークン認証(P1)。POST /auth/login に共有トークンを送ると
        `dwatch_session` cookie が発行される(httpOnly / Secure / SameSite=Lax / path=/)。
        cookie の値は設定値 `AUTH_TOKEN` と定数時間比較で突き合わせる(D-072)。
        判定は `proxy.ts` が全ルートで行う。

  parameters:
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    PerPage:
      name: per_page
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    PathId:
      name: id
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
      description: 1以上の整数。0・負値・非整数・安全整数超は422で弾く(D-123)
    GroupId:
      name: group_id
      in: query
      schema: { type: integer, minimum: 1 }
      description: |
        ホールディングス(`groups.id`)で絞り込み。傘下の全顧客の全施設が対象。

        1以上の整数。★0・負値・非整数・安全整数超・数字以外の混在(`1abc`)は422★
        (`PathId` と同じ作法。前方一致で `1abc` を `1` として通さない。D-123)。
        ★0 は `customers.group_id` の「ホールディングスなし」のセンチネル(D-011)で、
        有効なIDではないため422★。未指定・空文字(`?group_id=`)は「絞らない」。

        ★★customer_id と同時に指定したときは AND★★
        (そのホールディングスに属し、かつその顧客でもある施設。実質は
        「その顧客がそのホールディングス傘下なら顧客で絞った結果、そうでなければ0件」)。
        どちらか片方を捨てない。OR にしない。
        指定したIDが実在しないときは ★0件(404にしない)★。

        ★このパラメータを宣言しているエンドポイントでは、絞り込みが実際に効いている★
        (指示書AK 段階4 で一覧系11本、指示書AL 段階1 で `/reports/kpi` の当期まで完了)。
        ★ただし `/inspections` の `customer_id` だけは例外で、まだ絞り込みに使っていない★ —
        点検記録は画面もAPIも未実装のため(D-083)。★画面が復活したときに足すこと★。
        ★検証(422)は全エンドポイント共通に効いている★。
    CustomerId:
      name: customer_id
      in: query
      schema: { type: integer, minimum: 1 }
      description: |
        顧客(`customers.id`)で絞り込み。その顧客の全施設が対象。
        値域・エラー・AND の扱いは `group_id` と同じ(上の description を参照)。
    Period:
      name: period
      in: query
      required: true
      schema:
        type: string
        enum: [weekly, monthly, yearly]
      description: |
        週次/月次/年次の切替。前期比のラベル(前週/前月/前年)もこれに連動。

        ★yearly を追加した(2026-09-30 ユーザー指示。指示書AQ 決定1)★ —
        確定期間のサマリには ★週・月・年の3粒度★ が入るので、年次も
        レポート画面の表示単位として扱う。
        ★以前ここには「年次は範囲外。enum に yearly を追加しないこと」と
        書いてあったが、その前提は撤回された★
        (CLAUDE.md 4.7 の年次バッチはサマリの生成単位だが、
         画面の表示単位としても使う、に変わった)。

        ★period=yearly のときは month=0・week は省略(内部表現 0)★ —
        サマリの粒度判別が `month=0 AND week=0` = 年次であること(CLAUDE.md 4.7)に
        合わせる。組み合わせの検証は `Month` / `Week` の description を参照。
    Year:
      name: year
      in: query
      required: true
      schema: { type: integer, minimum: 2000, maximum: 2100, example: 2026 }
      description: |
        西暦4桁。範囲外は422で弾く(month / week と同じ作法)。

        下限2000 / 上限2100 の根拠:
          - サマリ4テーブルの `year` は `UnsignedSmallInt`(0〜65535)。
            そもそも収まらない値はDBに存在しえない。
          - 期間の日付は `lib/format/period.ts` が `YYYY-MM-DD` を組み立てるので
            ★4桁の西暦でなければ壊れる★(実測: year=0 や year=1000000 を
            period=weekly で送ると `addDays` が RangeError を投げて500になっていた)。
          - 実データの保持期間は2年。2000〜2100 はサービスの寿命を十分に超える。
        値そのものは `lib/config/index.ts` の `REPORT_YEAR` が正。
    Month:
      name: month
      in: query
      required: true
      schema: { type: integer, minimum: 0, maximum: 12, example: 7 }
      description: |
        月。★period=weekly / monthly のときは 1〜12★。

        ★0 は period=yearly のときだけ★(2026-09-30 ユーザー指示。
        指示書AQ 決定1 / 落とし穴6)。年次のサマリは `month=0 AND week=0` で
        表されるので(CLAUDE.md 4.7)、API の内部表現もそれに揃える。

        ★minimum を 1 から 0 に緩めたのは yearly のためだけ★ —
        period との組み合わせは422で弾く:
          - period=weekly / monthly で month=0 → ★422★(従来どおり不正)
          - period=yearly で month が 0 以外 → ★422★
        実装は `lib/reports/query.ts` の `parseReportQuery()`。
    Week:
      name: week
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 6
      description: |
        period=weekly のとき必須(第1週=1)。★monthly / yearly のときは無視★
        (どちらも内部表現は week=0)。

        月の第N週。週は日曜〜土曜だが、月をまたぐ週は月境界で分割し、
        それぞれの月の週として数える(D-071)。
        各月の第1週は必ず1日から始まり、最終週は必ず末日で終わる。
        週の長さは1〜7日で変わる。
        上限は6(1日が金曜または土曜の30日/31日月で第6週が発生する)。
        `lib/format/__tests__/period.test.ts` で2020〜2040年の全日について
        検証済み。

  responses:
    Unauthorized:
      description: |
        未認証。`dwatch_session` cookie が無いか、値が `AUTH_TOKEN` と一致しない。
        画面(HTMLナビゲーション)は /login へリダイレクトされるため、この401はAPIルートのみ。
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: unauthorized
              message: 認証が必要です
    Forbidden:
      description: |
        IP制限で拒否。`X-Forwarded-For` の末尾から `TRUSTED_PROXY_HOPS` 番目が
        許可リストに無いか、そもそも取得できない(nginx を経由しない直接アクセス)。
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: forbidden
              message: このIPアドレスからは利用できません
    NotFound:
      description: リソースが存在しない
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: バリデーションエラー
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: "validation_failed" }
            message: { type: string, example: "date_from〜date_toは731日以内で指定してください" }
            details:
              type: object
              nullable: true
              description: フィールド別エラー(422のとき)
              additionalProperties:
                type: array
                items: { type: string }

    Meta:
      type: object
      properties:
        total: { type: integer, example: 360 }
        page: { type: integer, example: 1 }
        per_page: { type: integer, example: 50 }

    Group:
      type: object
      description: |
        ホールディングス(`groups`)。セレクトリスト用の最小限の形。
        `inactive` は返さない(一覧は deleted_at でのみ絞る。GET /groups の description)。
      properties:
        id: { type: integer, example: 1 }
        group_name: { type: string, example: "バリューホールディングス" }

    Customer:
      type: object
      description: |
        顧客(`customers`。法人の単位。第11回 P-15)。セレクトリスト用の最小限の形。
        `contract` は返さない(customers.contract は削除依頼済み。D-010)。
      properties:
        id: { type: integer, example: 10 }
        customer_name: { type: string, example: "バリューマート" }
        group_id:
          type: integer
          nullable: true
          example: 1
          description: |
            所属ホールディングス。**null = 所属なし**。
            DB は NOT NULL で **0 がセンチネル**(D-011)だが、API では 0 を返さず null にする。
            変換を知っているのは `lib/format/group-id.ts` の `groupIdFromDb()` だけ
            (指示書AK §4-4。画面やルートに `=== 0` を散らさない)。

    CleanLogItem:
      type: object
      description: モックのLOG(f/m/s/d/t/h1/h30/dp/cp/g)に対応
      properties:
        id: { type: integer, example: 1001 }
        facility_id: { type: integer, example: 1 }
        facility_name: { type: string, example: "バリューマート渋谷本店" }
        model_name: { type: string, example: "PUDU CC1" }
        serial_number: { type: string, example: "8111R5628060011" }
        scheduled_at: { type: string, format: date-time, example: "2026-07-14T02:00:00+09:00" }
        status_1h:
          type: string
          nullable: true
          example: "Standby."
          description: |
            ★**事前チェック1回目**(早い方。分数の大きい方)の時点のロボット状態★(D-178。ユーザー判断 2026-10-16「案1」)。
            ★フィールド名は互換のため `status_1h` のまま★だが、★60分前とは限らない★ —
            何分前の事前チェックかは `status_1h_minutes` を見ること。
            列の割り当ては★分数の一致ではなく順番★: 本清掃に付く事前チェックの予定(`clean_timetables.precheck` の生値)を
            分数の大きい順に並べ、1つ目を `status_1h`、2つ目を `status_30m` に入れる
            (★設定で事前チェックの時刻が変わっても(例: 60/10)列から落ちない★。旧: `PRECHECK_MINUTES_BEFORE` と一致する分数だけを入れていた)。
            3つ以上あるときは開始に近い(分数の小さい)2つを残す(事前チェックは1清掃2回で固定なので通常は起きない。捨てた分数はログ `data.anomaly` に出す)。
            ★**null = 1回目の事前チェックの予定が無い**★(＝事前チェックの予定が1つも無い。ユーザー指示 2026-10-07
            「事前チェックがないものは-にしてください」。D-171)。画面は Pill を出さず「-」、CSV は空のセル。
            文字列(`"Standby."` を含む) = 予定がある。エラーが無ければ `"Standby."`、
            あれば `robot_error_reports.error_type` の生値(D-077。変換しない)。
            判定(◎○△×・出発成功/失敗)には使わない(D-170)。
            fixtures は日本語の暫定値(待機中/充電中 等。D-077 項3)
        status_1h_minutes:
          type: integer
          nullable: true
          example: 60
          description: |
            ★`status_1h`(事前チェック1回目)が本清掃開始の何分前のチェックか★(`clean_timetables.precheck` の生値。D-178)。
            ★常に返す(必須)★。★`status_1h` が null のときだけ null★(null どうしは必ずそろう)。
            ★生値を変換しない★ — 旧コード値の `1` / `2` も「1分前」「2分前」としてそのまま返す
            (`precheckMinutesFromCode()` と同じ。TeamWorks 第7回 A5-3)
        status_30m:
          type: string
          nullable: true
          example: "Standby."
          description: |
            ★**事前チェック2回目**(遅い方。分数の小さい方)の時点のロボット状態★(D-178)。
            ★フィールド名は互換のため `status_30m` のまま★だが、★30分前とは限らない★(`status_30m_minutes` を見る)。
            ★**null = 2回目の事前チェックの予定が無い**★(事前チェックの予定が0〜1つ。D-171)。
            予定が1つだけのときは、その分数によらず `status_1h` 側に入り、こちらは null。
            値の決め方は `status_1h` と同じ
        status_30m_minutes:
          type: integer
          nullable: true
          example: 30
          description: |
            ★`status_30m`(事前チェック2回目)が本清掃開始の何分前のチェックか★(D-178)。
            ★常に返す(必須)★。★`status_30m` が null のときだけ null★。値の決め方は `status_1h_minutes` と同じ
        departed:
          type: boolean
          example: true
          description: >-
            スケジュール通り出発したか。根拠は ★本清掃の clean_timetables 行に対応する
            clean_task_reports の行があるか★(指示書AQ §1-1。D-107 設計3-C)。
            行が無い(task_status=null)とき false(＝出発失敗。「出発失敗」と「未実施」は同一扱い)。
            判定は notExecutedFromReport()(lib/db/clean-log-mapping.ts)の1箇所
        task_status:
          type: integer
          nullable: true
          example: 4
          description: >-
            clean_task_reports.status の生値(★API では数字のまま返す★。表示名への変換は画面・CSV が行う)。
            値の意味(TeamWorks 第10回 N-2。D-155 項2): 0 清掃開始前 / 1 清掃中 / 2 清掃中断 /
            3 清掃キャンセル / 4 清掃正常終了 / 5 清掃異常終了(lib/config の CLEAN_TASK_STATUS_LABELS)。
            ★表に無い値(CSV実測の 6 など。意味は未受領)は、画面・CSV でも数字のまま出す★
            (「その他」「不明」に潰さない)。★判定(◎○△×・出発失敗)には使わない★(D-155 項2)。
            旧 clean_error のコード表(lib/config の CLEAN_ERROR)には対応づけない(別のテーブルの別のコード表)。
            null は「対応する clean_task_reports の行が無い」ことを表し(status 列自体は NOT NULL)、
            出発失敗として扱う(D-107 設計3-C。行ごと消さずに画面へ出す)。
            null のとき departed=false / completion_rate=null になる。
            詳細ログの「清掃結果」列は null を「出発失敗」、それ以外を表示名にして t() で訳す。
            CSV は日本語の表示名のまま出す(lib/format/clean-result.ts の formatCleanTaskStatus())。
            ★旧名 clean_error から改名した★(指示書AQ §5-2。2026-09-30) —
            中身が clean_status_histories.clean_error から clean_task_reports.status に変わったのに
            名前が同じだと、読み手が旧コード表(0:正常 / 1:異常(清掃未実施) …)で解釈するため
        result_pending:
          type: boolean
          example: false
          description: >-
            ★結果待ちか★(★常に返す(必須)★。ユーザー判断 2026-10-07。D-173)。
            レポートの行がまだ無いが、予定の終了(無ければ開始)から RESULT_WAIT_MINUTES 分
            (lib/config。一旦120分)以内の清掃(清掃中も含む)は true。判定は
            isResultPending()(lib/clean-logs/result-pending.ts)の1箇所。
            ★true でも departed=false / task_status=null / completion_rate=null / grade="×" は変えない★
            (不変条件 departed=false ⟺ completion_rate=null を守る)。
            この旗を見ない処理は今までどおり出発失敗として扱う(安全側)。
            画面は出発「…」・清掃結果「結果待ち」・完了率と判定は「-」。
            絞り込み result=problem / critical には出さない(all には出す)。
            ダッシュボードは summary.pending に数え、アラートの出発失敗・連続問題日数・当期の率には数えない。
            レポートがある行・確定した出発失敗は false
        completion_rate:
          type: number
          format: float
          nullable: true
          example: 92.0
          description: >-
            タスク完了率(%)。1回ごとの完了率(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。`task_area = 0` は 0)。D-155。第11回 P-2・P-5 で確定。D-158。
            ★clean_task_reports.clean_ratio 列は使わない★(旧: D-151 では列の生値だった)。
            対応する clean_task_reports の行が無い(task_status=null)とき null。
            行があって clean_area=0(task_area > 0)なら 0(null にしない)。
            ★平均側では丸めない★
        grade:
          type: string
          enum: ["◎", "○", "△", "×"]
          example: "◎"
          description: >-
            サーバ側算出(◎90%以上/○70%以上/△10%以上70%未満/×未開始or10%未満)。
            ★境界は上のランクに入る(90.0=◎ / 70.0=○)。×の境界だけは「10%未満」なので
            10.0 は △★(D-139。ユーザー指示 2026-09-16。旧 D-020b は破棄)
        consecutive_problem_days:
          type: integer
          example: 0
          description: >-
            △以下または出発失敗の連続日数。表示上限で頭打ちになる
            (consecutive_problem_days_capped を参照)
        consecutive_problem_days_capped:
          type: boolean
          example: false
          description: >-
            consecutive_problem_days が表示上限(GET /settings/display-limits の
            consecutive_days_max)で頭打ちになっているか。true のとき実際の連続日数は
            この値より大きい(「{n}日以上」と表示する)。上限ちょうどのときは false。
            escalation_level は上限をかける前の生値で判定する(上限は表示のためだけ)
        escalation_level:
          type: string
          enum: [L1, L2, L3]
          nullable: true
          example: null
          description: 問題行のみ設定(L1=初回/L2=2日連続/L3=3日以上)

    AreaCompletionItem:
      type: object
      description: |
        GET /clean-logs/{id}/areas の1件。1エリア(=1清掃ロボット)につき1件。
        出所は TeamWorks 第8回 A9-2。データ元は clean_task_reports(指示書AQ 決定2・決定3)。
      required: [section_id, section_name, completion_rate, grade]
      properties:
        section_id: { type: integer, example: 107 }
        section_name:
          type: string
          example: "清掃エリア（ロボット107）"
          description: sections.section_name の生値。当方で整形しない
        completion_rate:
          type: number
          format: float
          nullable: true
          example: 42.5
          description: |
            1回ごとの完了率(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。`task_area = 0` は 0)。D-155。第11回 P-2・P-5 で確定。D-158。
            ★未実施は null(0 ではない)★ — 「0%しか清掃できなかった」と区別する。
            ★GET /clean-logs の completion_rate と同じ出し方★。
        grade:
          type: string
          enum: ["◎", "○", "△", "×"]
          example: "△"
          description: completion_rate を丸める前の生値で判定したもの(null は ×)

    PrecheckItem:
      type: object
      description: モックのPRECHK(sev/serial/fac/sch/st/ok/at/dt/note)に対応
      properties:
        # 事前チェック由来はrobot_error_reports.error_id(varchar(50))、
        # 出発失敗は本清掃のclean_timetables.idを文字列化した値(D-070 1-2 / D-104 / D-105)
        id: { type: string, example: "RER-1001-1" }
        severity:
          type: string
          enum: [precheck, launch_failed]
          description: >-
            precheck=事前チェック異常(何分前かはprecheck_minutes) / launch_failed=出発失敗。
            clean_timetables.precheckは設定値に応じた任意の分数を取りうるため、
            分数ごとのenumは持たない(D-093 項2 の案A / D-105)
        precheck_minutes:
          type: integer
          nullable: true
          example: 60
          description: 清掃開始予定時刻の何分前のチェックか。launch_failedのときはnull
        serial_number: { type: string, example: "8111R5628060011" }
        facility_id: { type: integer, example: 1 }
        facility_name: { type: string, example: "バリューマート渋谷本店" }
        scheduled_at: { type: string, format: date-time }
        robot_status: { type: string, example: "充電中" }
        detected_at: { type: string, format: date-time, description: 障害検知日時 }
        resolution_status:
          type: string
          enum: [unresolved, in_progress, resolved]
        responded_at:
          type: string
          format: date-time
          nullable: true
          description: 対応開始日時(対応中以降で設定)
        resolved_at:
          type: string
          format: date-time
          nullable: true
          description: 解消日時
        note: { type: string, nullable: true }
        battery_level:
          type: integer
          nullable: true
          example: null
          description: >-
            事前チェック時点のバッテリー残量(%)。robots.batteryは現在値のみで履歴が無いため、
            実DB経由では常にnullになる(D-090 項8 / D-105)
        main_run_succeeded:
          type: boolean
          example: false
          description: >-
            ★親の本清掃が成功したか★(ユーザー指示 2026-10-07。D-171)。
            true = 親の本清掃に clean_task_reports の行があり、完了率の判定が ◎○
            (ダッシュボードの「成功」と同じ定義。完了率は GET /clean-logs の completion_rate と同じ出し方)。
            それ以外(レポート行が無い・△×)は false。★launch_failed の行は常に false★。
            ★表示のためだけの値★ — resolution_status・summary の件数は変えない(本清掃が成功しても未解消は未解消)。
            画面はこの値が true のカードを控えめに出し「本清掃は成功」と添える

    PrecheckListResponse:
      type: object
      properties:
        summary:
          type: object
          description: >-
            statusパラメータの影響を受けない。日付範囲(date_from/date_to)で絞った全件に対する集計。
            meta.total(一覧の総件数。statusで絞った後の件数)とは意味が違う点に注意
          properties:
            unresolved:
              type: integer
              example: 4
              description: 解消していない件数(未解消+対応中)。resolvedとの合計がtotalになる
            resolved: { type: integer, example: 2 }
            total: { type: integer, example: 6 }
        items:
          type: array
          items: { $ref: "#/components/schemas/PrecheckItem" }
        meta: { $ref: "#/components/schemas/Meta" }

    ErrorReportItem:
      type: object
      description: GET /errors の1行(robot_error_reports の1行。指示書AW)
      required:
        - id
        - detected_at
        - facility_id
        - facility_name
        - serial_number
        - error_type
        - error_level
        - clean_timetable_id
        - schedule_link
        - precheck_minutes
        - resolution_status
        - responded_at
        - resolved_at
        - note
      properties:
        id:
          type: string
          example: "RER-1001-1"
          description: robot_error_reports.error_id(varchar(50))。数値に変換しない(D-105)
        detected_at:
          type: string
          format: date-time
          description: 検知時刻。errored_at、無ければ created_at(ISO8601、+09:00)
        facility_id: { type: integer, example: 1 }
        facility_name: { type: string, example: "バリューマート北町店" }
        serial_number:
          type: string
          example: "8111R5628060011"
          description: DB の値のまま。制御盤のエラーは制御盤のシリアル、セキュリティ・清掃開始失敗は "0"(D-158)
        error_type:
          type: string
          example: "EmergencyStop"
          description: DB の値のまま(区分を作らない・変換しない。D-077 / D-160 項3)
        error_level:
          type: string
          example: "CRITICAL"
          description: DB の値のまま
        clean_timetable_id:
          type: integer
          nullable: true
          example: 9001
          description: 紐づく予定の id(clean_timetables.id)。画面には出さない(「予定不明」の調査用)
        schedule_link:
          type: string
          enum: [main, precheck, none, unknown]
          description: |
            紐づく予定の区分。
            main=本清掃の予定 / precheck=事前チェックの予定(何分前かは precheck_minutes) /
            none=clean_timetable_id が null(予定なし) /
            unknown=clean_timetable_id があるが clean_timetables にも clean_timetable_histories にも無い(予定不明)
        precheck_minutes:
          type: integer
          nullable: true
          example: 30
          description: schedule_link=precheck のときだけ。生の分数(設定の分数に無い値でもそのまま)。それ以外は null
        resolution_status:
          type: string
          enum: [unresolved, in_progress, resolved]
        responded_at:
          type: string
          format: date-time
          nullable: true
          description: 対応開始日時(対応中以降で設定)
        resolved_at:
          type: string
          format: date-time
          nullable: true
          description: 解消日時
        note:
          type: string
          nullable: true
          description: 備考(robot_error_reports.comment)

    ErrorReportListResponse:
      type: object
      description: GET /errors の応答。summary は無い
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/ErrorReportItem" }
        meta: { $ref: "#/components/schemas/Meta" }

    KpiValue:
      type: object
      properties:
        value:
          type: number
          format: float
          nullable: true
          example: 91.2
          description: >-
            丸める前の生値。分母が0の期間(全施設定休日の週など)は null。
            「予定が1件もない期間」と「予定はあったが全部失敗した(0%)」を区別する
        prev:
          type: number
          format: float
          nullable: true
          example: 89.5
          description: 前期値(週次なら前週、月次なら前月)。前期データなしはnull
        diff:
          type: number
          format: float
          nullable: true
          example: 1.7
          description: value - prev。フロントは正負で▲▼色分け

    ReportKpi:
      type: object
      description: |
        GET /reports/kpi のレスポンス。KPI4指標それぞれに前期比を持つ。
        - 稼働率       = 正常終了(status = 4)の回数 ÷ 清掃予定回数 × 100
        - 出発成功率   = 開始できた回数(レポート行がある回数)÷ 清掃予定回数 × 100(時刻の条件なし)
        - 清掃完了率   = Σ 1回ごとの完了率 ÷ 1回ごとの完了率が null でない清掃の回数
        - エラー発生率 = エラー発生回数 ÷ 清掃予定回数 × 100(上限で切らない)
        確定期間はサマリの値そのまま／当期はアプリが同じ式で集計(第11回 P-6。D-158)。
      properties:
        operation_rate: { $ref: "#/components/schemas/KpiValue" }
        launch_rate: { $ref: "#/components/schemas/KpiValue" }
        completion_rate: { $ref: "#/components/schemas/KpiValue" }
        error_rate: { $ref: "#/components/schemas/KpiValue" }

    FacilityPerformance:
      type: object
      description: |
        モックのRANK(f/ar/arP/dp/dpP/cv/cvP/er/erP/s/cont)に対応。
        4指標の式は ReportKpi と同じ:
        - 稼働率       = 正常終了(status = 4)の回数 ÷ 清掃予定回数 × 100
        - 出発成功率   = 開始できた回数(レポート行がある回数)÷ 清掃予定回数 × 100(時刻の条件なし)
        - 清掃完了率   = Σ 1回ごとの完了率 ÷ 1回ごとの完了率が null でない清掃の回数
        - エラー発生率 = エラー発生回数 ÷ 清掃予定回数 × 100(上限で切らない)
        確定期間はサマリの値そのまま／当期はアプリが同じ式で集計(第11回 P-6。D-158)。
      properties:
        facility_id: { type: integer, example: 5 }
        facility_name: { type: string, example: "バリューマート蒲田駅前店" }
        serial_number:
          type: string
          example: "8111R5628060011"
          description: >-
            ロボットのシリアル番号。serial クエリパラメータでの絞り込みキーなので
            結果にも含める。P1は1清掃エリア=1ロボットの前提
        operation_rate: { $ref: "#/components/schemas/KpiValue" }
        launch_rate: { $ref: "#/components/schemas/KpiValue" }
        completion_rate: { $ref: "#/components/schemas/KpiValue" }
        error_rate: { $ref: "#/components/schemas/KpiValue" }
        status:
          type: string
          enum: [good, warning, bad, no_data]
          description: >-
            良好/注意/要改善(算出基準は確認事項#の回答待ち、暫定はサーバ設定値)。
            no_data は稼働率が算出できない(その期間の清掃予定が0件、または予定が全部これから)ときの値(決定1)。
            当期の稼働率は正常終了 ÷ 予定(第11回 P-6=A。以前は出発成功率と同じ値だった)
        consecutive_problem_days:
          type: integer
          example: 5
          description: >-
            △以下または出発失敗の連続日数。表示上限で頭打ちになる
            (consecutive_problem_days_capped を参照)
        consecutive_problem_days_capped:
          type: boolean
          example: false
          description: >-
            consecutive_problem_days が表示上限(GET /settings/display-limits の
            consecutive_days_max)で頭打ちになっているか。true のとき実際の連続日数は
            この値より大きい(「{n}日以上」と表示する)。上限ちょうどのときは false。
            escalation_level は上限をかける前の生値で判定する(上限は表示のためだけ)

    ContinuousProblem:
      type: object
      description: |
        連続問題施設1件。GET /reports/continuous-problems の items。
        L1(初回)は返さない(2日以上のみ)。
      properties:
        facility_id: { type: integer, example: 5 }
        facility_name: { type: string, example: "バリューマート蒲田駅前店" }
        consecutive_problem_days:
          type: integer
          example: 5
          description: >-
            △以下または出発失敗の連続日数。カレンダー日ではなくスケジュール存在日の
            連続で数える(D-026)。CleanLogItem / FacilityPerformance と同じ項目名。
            表示上限で頭打ちになる(consecutive_problem_days_capped を参照)
        consecutive_problem_days_capped:
          type: boolean
          example: false
          description: >-
            consecutive_problem_days が表示上限(GET /settings/display-limits の
            consecutive_days_max)で頭打ちになっているか。true のとき実際の連続日数は
            この値より大きい(「{n}日以上」と表示する)。上限ちょうどのときは false。
            escalation_level は上限をかける前の生値で判定する(上限は表示のためだけ)
        escalation_level:
          type: string
          enum: [L1, L2, L3]
          example: L3
          description: L1=初回 / L2=2日連続 / L3=3日以上。サーバ側算出
        last_problem_on:
          type: string
          format: date
          example: "2026-07-13"
          description: 直近で問題が発生した日
        summary:
          type: string
          example: "{n}日連続で△以下（うち{m}日は出発失敗）"
          description: >-
            連続の内容を事実だけで述べたテンプレート文字列(D-111)。原因の推定も対処の助言も含まない。
            画面が {n} に consecutive_problem_days を、{m} に launch_failed_days を差し込む(方式A)。
            サーバは数値を埋めずにテンプレートのまま返す(完成した日本語文を返すと EN 切替で和文が残るため)。
            返す文字列は次の3種のいずれか:
            出発失敗の日が無いとき「{n}日連続で完了率が△以下」/
            全日が出発失敗のとき「{n}日連続で出発失敗」/
            混在するとき「{n}日連続で△以下（うち{m}日は出発失敗）」。
            △ と × は書き分けず「△以下」に畳む(1つの連続の中で混ざるため。モック:713 の副題と同じ粒度)。
            consecutive_problem_days_capped が true のときは上限用の3種のいずれかを返す:
            「{n}日以上連続で完了率が△以下」/「{n}日以上連続で出発失敗」/
            「{n}日以上連続で△以下（出発失敗を含む）」。
            ★上限用の混在テンプレートには {m} が無い★(頭打ちにした n では
            0 <= m <= n が成り立たなくなるため。m を min(m, n) に丸めるのは捏造になる)
        launch_failed_days:
          type: integer
          example: 3
          description: >-
            連続の中で出発失敗(未実施)があった日の数。0 <= launch_failed_days <= consecutive_problem_days。
            summary の {m} に差し込む。同じ日に未実施の行と実施済みの行が混在する場合は、その日を
            出発失敗の日として1日だけ数える(D-109決定2 の同一日OR畳み込みと同じ規則)。
            ★任意項目★: fixtures(手書きの完成文)を変更しないための措置であり、
            実DB経由(prismaSource)では常に数値が入る。
            ★consecutive_problem_days_capped が true のときは省略する★
            (頭打ちにした consecutive_problem_days との不等式が壊れるため)

    ErrorBreakdownItem:
      type: object
      description: |
        エラー種別の内訳1件。GET /reports/error-breakdown の items。
        type(生値の文字列) + label + count の3点セットは /dashboard の failure_breakdown に揃えている。
        type は robot_error_reports.error_type をそのまま返す(変換しない。D-077)。
      properties:
        type:
          type: string
          example: EmergencyStop
          description: >-
            robot_error_reports.error_type の値をそのまま返す。サーバ側で変換しない(D-077)。
            PUDU Cloud 由来で列挙が未定義のため、推測でマッピングしない。
            既知の値の一覧は docs/handoff/analysis/error-type-mapping.md に24件あるが、
            これは参考資料であって enum ではない。実データで増減しうる(D-088)
        label: { type: string, example: "EmergencyStop" }
        count: { type: integer, example: 12 }
        rate:
          type: number
          format: float
          example: 34.3
          description: 全エラー件数に対する割合(%)。丸める前の生値をサーバが返す

    CleanAreaTotal:
      type: object
      description: |
        清掃面積の**全体合計**(GET /clean-area の total)。
        ★`group_by` を変えても変わらない★ — 内訳の分け方が変わるだけで母集団は同じ。
        粒度タブを切り替えて合計が動いたら、ロールアップが壊れている(指示書AN §5-3 B-5)。

        ★単位は含めない★ — 数値だけを返し、`㎡` は画面が `AREA_UNIT` から付ける(§2-4)。
      properties:
        clean_area:
          type: number
          format: float
          example: 341635
          description: |
            Σ`clean_task_reports.clean_area`(★予定外を含む全行★)。★総清掃面積の表示用★(指示書AQ §1-3)。
            ★完了率の分子ではない★(`completion_rate` は1回ごとの完了率の単純平均)
        task_area:
          type: number
          format: float
          example: 494000
          description: |
            Σ`clean_task_reports.task_area`(★予定外を含む全行★)。★表示用★。★完了率の分母ではない★
        completion_rate:
          type: number
          format: float
          nullable: true
          example: 78.5
          description: |
            清掃完了率 = ★本清掃の1回ごとの完了率(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで
            切り捨て。`task_area = 0` は 0)の単純平均(清掃1回＝1票)★(D-155。第11回 P-2・P-5 で確定。D-158)。
            ★Σclean_area ÷ Σtask_area ではない★。★予定外の清掃は含まない★。
            (`lib/judge/kpi.ts` の `runCompletionRate()` / `meanCleanRatio()`)。
            ★丸める前の生値★(CLAUDE.md §4.3 / §9.3。整数化は表示層だけ)。
            ★平均を取れる清掃が0回のときは null★ — ∞ や NaN にしない。
        executed_count:
          type: integer
          example: 538
          description: |
            清掃実施回数(= 対象期間・対象施設の `clean_task_reports` のうち、★本清掃(`precheck = 0`)の予定に
            紐づく行の数★。★予定外(`clean_timetable_id` が NULL)を含まない★。D-155 項3)

    CleanAreaRow:
      type: object
      description: |
        清掃面積の内訳1行(GET /clean-area の items)。`group_by` で1行の意味が変わる。
      properties:
        scope:
          type: string
          enum: [group, customer, facility]
          description: この行の粒度(リクエストの `group_by` と同じ値)
        id:
          type: integer
          nullable: true
          example: 1
          description: |
            その粒度でのID(group なら `groups.id`、customer なら `customers.id`、
            facility なら `facilities.id`)。
            ★`scope: group` の「グループなし」の行だけ null★ — `customers.group_id` の 0 は
            「所属なし」のセンチネル(D-011)で、**存在しない「グループ0」を作らない**。
            顧客・施設は NOT NULL なので null にならない。
        name:
          type: string
          example: "バリューホールディングス"
          description: 画面に出す名前。グループなしの行は「グループなし」
        group_id:
          type: integer
          nullable: true
          description: 所属ホールディングス。**null = 所属なし**(0 を返さない。D-011)
        group_name: { type: string, nullable: true }
        customer_id:
          type: integer
          nullable: true
          description: |
            所属顧客。★`scope: group` のときは常に null★ —
            1つのホールディングスに顧客が複数ぶら下がりうるので「その行の顧客」が
            1つに決まらない。適当に1つ選んで入れない(入力順で変わり、画面が嘘をつく)。
        customer_name: { type: string, nullable: true }
        clean_area:
          type: number
          format: float
          example: 120500.5
          description: |
            Σ清掃面積(`clean_task_reports.clean_area`。★予定外を含む全行★)。★単位は含めない★(画面が `AREA_UNIT` から付ける)。
            ★表示用。完了率の分子ではない★(指示書AQ §1-3)
        task_area:
          type: number
          format: float
          example: 180000
          description: Σ清掃対象面積(`clean_task_reports.task_area`。★予定外を含む全行★)。★表示用。完了率の分母ではない★
        completion_rate:
          type: number
          format: float
          nullable: true
          example: 76.3
          description: |
            この行の完了率 = ★この行に含まれる本清掃の1回ごとの完了率の単純平均(清掃1回＝1票)★
            (CleanAreaTotal と同じ式。D-155)。★施設ごとの平均を平均しない★。★予定外の清掃は含まない★。
            ★丸める前の生値★。★平均を取れる清掃が0回なら null★(CleanAreaTotal と同じ規則)。
        executed_count:
          type: integer
          example: 180
          description: この行に含まれる清掃実施回数(★本清掃の予定に紐づく行の数。予定外を含まない★。D-155 項3)

    AdviceItem:
      type: object
      description: 行展開アドバイス1件
      properties:
        rule:
          type: string
          enum: [R1, R2, R3, R4, B1, B2, B3]
          example: R3
        level:
          type: integer
          enum: [1, 2, 3]
          example: 2
          description: 1=注意 2=警告 3=重大
        icon: { type: string, example: "🔶" }
        title: { type: string, example: "2日連続で完了率低下（R3）" }
        estimated_cause:
          type: string
          example: "恒常的な環境変化の可能性があります。"
        recommended_actions:
          type: array
          items: { type: string }
          example: ["現場責任者へのヒアリング", "運用手順の再教育", "消耗品交換の手配"]

    AdviceSetting:
      type: object
      description: 運用設定でのアドバイス文言
      properties:
        rule:
          type: string
          enum: [R1, R2, R3, R4, B1, B2, B3]
        phase:
          type: string
          enum: [A, B]
        level: { type: integer, enum: [1, 2, 3] }
        icon: { type: string }
        title: { type: string }
        text: { type: string }
        actions:
          type: array
          items: { type: string }
        condition:
          type: string
          description: 発火条件の説明(編集不可)
          example: "同一施設で2日以上連続して△以下または出発失敗"
        customized:
          type: boolean
          description: 既定値から変更されているか

    DisplayLimitSetting:
      type: object
      description: |
        表示上限1件。GET /settings/display-limits の items。
        min / max はサーバ側の検証範囲そのもの。画面の入力欄はこの値を使う
        (画面に範囲を直書きすると、サーバの検証とずれる)。
      properties:
        key:
          type: string
          enum: [consecutive_days_max]
          description: consecutive_days_max = 連続日数の表示上限(スケジュール存在日ベース)
        value: { type: integer, example: 30, description: 現在有効な値 }
        default: { type: integer, example: 30, description: lib/config の既定値 }
        min: { type: integer, example: 3, description: 設定できる下限(L3の日数。これを下回らせない) }
        max: { type: integer, example: 365, description: 設定できる上限 }
        customized: { type: boolean, description: 既定値から変更されているか }

    DsDevice:
      type: object
      description: モックのDSDEV(id/f/st/lc/n)に対応。トークン期限(te)は仕様確定により除外
      properties:
        serial_number: { type: string, example: "DS-001" }
        facility_id: { type: integer, example: 1 }
        facility_name: { type: string, example: "バリューマート渋谷本店" }
        connection_status:
          type: string
          enum: [ok, error]
          description: "正常 / 異常(D-065: controls.statusは0/1の2値。通信断は異常と同一)"
        last_reported_at: { type: string, format: date-time, description: 最終通信日時 }
        note:
          type: string
          nullable: true
          example: "定期点検実施済み"
          description: |
            運用担当がビューアーから入力するメモ。検知側が生成する値ではない。
            メインDBに対応カラムが無いため、DFA側の独自ストアに保存する
            (PATCH /ds-devices/{serial_number}/note)。

    DsDeviceListResponse:
      type: object
      properties:
        summary:
          type: object
          properties:
            total: { type: integer, example: 5 }
            ok: { type: integer, example: 3 }
            error: { type: integer, example: 2, description: 異常の件数 }
        items:
          type: array
          items: { $ref: "#/components/schemas/DsDevice" }
        meta: { $ref: "#/components/schemas/Meta" }

    DsAlert:
      type: object
      description: モックのDSA(type/dev/fac/tm/det/st/mm)に対応。トークン期限種別は除外
      properties:
        id: { type: integer, example: 1 }
        alert_type:
          type: string
          enum: [security_off, disconnected]
          description: セキュリティ無効 / 通信断
        device_serial: { type: string, example: "DS-001" }
        facility_id: { type: integer, example: 1 }
        facility_name: { type: string, example: "バリューマート渋谷本店" }
        section_name:
          type: string
          example: "1F売り場"
          description: 清掃エリア名。実DB上の取得元が未確定で fixtures では暫定のダミー値
        detected_at: { type: string, format: date-time }
        resolution_status:
          type: string
          enum: [pending, in_progress, done]
          description: 未対応 / 対応中 / 対応済み
        responded_at:
          type: string
          format: date-time
          nullable: true
          description: 対応開始日時(対応中以降で設定)
        resolved_at:
          type: string
          format: date-time
          nullable: true
          description: 解消日時
        memo: { type: string, nullable: true }

    DsAlertListResponse:
      type: object
      properties:
        summary:
          type: object
          properties:
            pending: { type: integer, example: 2, description: 未対応 }
            in_progress: { type: integer, example: 1, description: 対応中 }
            done: { type: integer, example: 1, description: 対応済み }
        items:
          type: array
          items: { $ref: "#/components/schemas/DsAlert" }
        meta: { $ref: "#/components/schemas/Meta" }

    Facility:
      type: object
      description: モックのROBOTS配列に対応
      properties:
        id: { type: integer, example: 5 }
        facility_name: { type: string, example: "バリューマート渋谷本店" }
        customer_id:
          type: integer
          example: 10
          description: |
            所属する顧客(`facilities.customer_id`)。**NOT NULL なので null にならない**
            (指示書AK 段階5)。
        customer_name: { type: string, example: "バリューマート", description: 一覧の「ブランド」列 }
        group_id:
          type: integer
          nullable: true
          example: 1
          description: |
            所属ホールディングス(`customers.group_id`)。**null = 所属なし**。
            DB は NOT NULL で **0 がセンチネル**(D-011)だが、API では 0 を返さず null にする。
            変換を知っているのは `lib/format/group-id.ts` の `groupIdFromDb()` だけ
            (指示書AK §4-4。画面やルートに `=== 0` を散らさない)。
        group_name:
          type: string
          nullable: true
          example: "バリューホールディングス"
          description: "`group_id` が null のときは null。"
        prefecture: { type: string, example: "東京都" }
        address: { type: string, example: "(ダミー住所)" }
        phone: { type: string, nullable: true }
        manager_name: { type: string, nullable: true, description: 責任者名 }
        charger_name: { type: string, nullable: true, description: 担当者名 }
        email: { type: string, nullable: true }
        monitoring_enabled: { type: boolean, description: D-Watch契約(○/ー) }
        d_security_enabled: { type: boolean, description: D-Sec契約(○/ー) }
        robots:
          type: array
          items:
            type: object
            properties:
              id: { type: integer, example: 12 }
              maker: { type: string, example: "PUDU" }
              model: { type: string, example: "CC1" }
              serial_number: { type: string, example: "8111R5628060011" }

    Schedule:
      type: object
      description: モックのSCHED(s/f/d/st/en/on)に対応
      properties:
        id: { type: integer, example: 21 }
        serial_number: { type: string, example: "8111R5628060011" }
        facility_id: { type: integer, example: 1 }
        facility_name: { type: string, example: "バリューマート渋谷本店" }
        weekdays:
          type: string
          example: "1111111"
          description: 日月火水木金土(清掃する曜日=1)。link_schedules同様の7桁形式
        weekdays_label: { type: string, example: "毎日", description: 表示用ラベル(サーバ生成) }
        start_time: { type: string, example: "02:00" }
        end_time: { type: string, example: "03:00" }
        active: { type: boolean }

    InspectionListItem:
      type: object
      description: モックのINSPECTIONS配列に対応。ブランドはfacility→customer経由で導出
      properties:
        id: { type: integer, example: 3 }
        visited_on: { type: string, format: date, example: "2026-03-11" }
        inspector_name: { type: string, example: "点検 太郎" }
        inspector_company: { type: string, example: "DFA Robotics" }
        customer_name: { type: string, example: "バリューマート", description: 一覧の「ブランド」列 }
        facility_name: { type: string, example: "(施設名)" }
        serial_number: { type: string, example: "8111R5628060033" }
        has_notes: { type: boolean, example: true }

    InspectionDetail:
      allOf:
        - $ref: "#/components/schemas/InspectionListItem"
        - type: object
          properties:
            notes: { type: string, nullable: true }
            photos:
              type: array
              description: Before/After×7項目。未撮影の組み合わせは配列に含めない
              items:
                type: object
                properties:
                  item_type:
                    type: string
                    enum: [brush, dust_box, filter, squeegee, suction_hose, waste_tank, brush_back]
                    description: ブラシ/ダストボックス/フィルター/スクイジー/吸引ホース/汚水タンク/ブラシ裏面
                  phase:
                    type: string
                    enum: [before, after]
                  url:
                    type: string
                    format: uri
                    description: 署名付き一時URL(有効期限あり)
