Newer
Older
client_dwatch / lib / fixtures / clean-task-reports.ts
import { CLEAN_TIMETABLE_ID_UNSCHEDULED, RUN_COMPLETION_RATE } from "@/lib/config";
import { cleanLogs, history } from "./clean-logs";
import { facilities } from "./facilities";
import { cleanTaskReportStatus } from "./task-status-rule";

/**
 * `clean_task_reports` のサンプルデータ(指示書AQ 決定2 / 担当F)
 *
 * ★何のためのファイルか★: 指示書AQ の決定2 で ★清掃実績の取得元が
 * `clean_status_histories` → `clean_task_reports` に変わる★。
 * ローカルDBにも fixtures にも ★1行も無い★ ため、切替後は全画面が0件になる(落とし穴5)。
 * ★このファイルと `scripts/seed-local-db.ts` の投入が、他担当の動作確認の土台★。
 *
 * ★担当F の時点では `lib/fixtures/` の既存ファイルを変更せずに追加した★(`lib/fixtures/index.ts` への
 * `export *` 1行を除く)。その後、担当J2 が `clean-logs.ts` に `task_status` を足し、担当M が
 * ★`status` の規則を `./task-status-rule.ts` に切り出して両ファイルから import する形にした★(循環 import の解消)。
 *
 * ---------------------------------------------------------------------------
 * ★★実データの形(出所: ユーザー提供CSV `clean_task_reports.csv` 189行。2026-09-30 受領)★★
 *
 * | 実測 | 値 |
 * |---|---|
 * | `report_id` | ★19桁の数値文字列★(例 `1295486100215435264`)。`varchar(20)` |
 * | `clean_timetable_id` | ★CSV では189行すべて NULL★。★NULL は予定外のタスク★(予定のある清掃には id が入る。`CLAUDE.md` §4.7。旧: 「今後 TeamWorks が埋める前提」— 誤り)。★`0` も予定外のタスク★(TeamWorks 回答 2026-10-27。`cleanTaskReportZeroTimetableRows`) |
 * | `status` | ★3:2件 / 4:161件 / 5:4件 / 6:22件★(値の意味は第10回 N-2 で受領。★6 は Pudu の値で、先方も意味が分からない(第11回 P-4。D-158)★。分かれば先方がデータを入れる。★数字のまま表示する★) |
 * | `task_area` | ★清掃エリアごとにほぼ一定★(例: あるエリアは19行すべて `878.524992`) |
 * | `clean_area` | ★毎回変動し、68.3%(129/189行)で `task_area` を超える★(★fixtures では再現しない。約束3★) |
 * | `clean_ratio` | ★面積比とは一致しない★(例: 面積比130.0% のとき `clean_ratio` は 76.0)。★列は完了率に使っていない★(D-155) |
 * | `ended_at` | ★nullable★ |
 * | `clean_time` | 秒(0 の行もある) |
 *
 * ---------------------------------------------------------------------------
 * ★★設計上の約束(読まずに直さないこと)★★
 *
 * 1. ★未実施の清掃には行を作らない★ — 指示書AQ 1-1 の
 *    「未実施 = 本清掃の `clean_timetables` 行に対応する `clean_task_reports` の行が無い」。
 *    ★`clean_task_reports` には `clean_error` 相当の列が無い★ので、行の有無だけが未実施の根拠。
 *    ★既存 `lib/fixtures/clean-logs.ts` は `clean_error` を `departed ? normal : notExecuted`
 *    で導出していて `null` の行を作れない構造だった(`:66`)★が、ここは ★そもそも
 *    `clean_error` 列を持たない★ ので同じ轍は踏まない。
 *
 * 2. ★`clean_ratio` 列は完了率に使っていない★(D-155)。★列に戻すときのために、清掃ログ(`cleanLogs`)の
 *    `completion_rate` と同じ値を入れてある★(戻すときに fixtures を作り直さずに済む。指示書AS D6)。
 *
 * 3. ★`clean_area` は清掃ログの完了率から作る★(2026-10-02。D-155 / 指示書AS 段階6-1)—
 *    完了率は ★面積から `runCompletionRate()`(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで
 *    切り捨て)★ で決まるようになったので、面積を完了率と独立に作ると
 *    ★DB 経由(`USE_FIXTURES=false`)で `docs/fixtures.md` §2.1 の境界値(90.1 / 90.0 / 89.5 / 70.1 /
 *    70.0 / 10.1 / 10.0 / 9.9 / 0)が全部壊れる★。`cleanTaskReportCleanArea()` の docstring を参照。
 *    ★旧約束3(面積を `clean_ratio` から逆算しない。実測の分布 68.3% が `task_area` 超えに寄せる)は廃止★ —
 *    D-151 の「完了率 = `clean_ratio` 列」が本当に効いているかを確かめるための約束だったが、
 *    D-155 で完了率が面積から決まるようになり、前提が逆になった。
 *
 * 4. ★`status` は生値(数字)で持つ★ — 表示名にするのは画面・CSV(`formatCleanTaskStatus()`。D-155 項2)。
 *    ここで `CLEAN_ERROR` に対応づけたり日本語に直したりしない。
 *
 * 5. ★冪等★ — `Math.random()` を使わず、すべて ★清掃ログの id から決まる純粋関数★ にする。
 *    ★`scripts/seed-local-db.ts` は同じ関数を import する★ので、
 *    ★`USE_FIXTURES` の true/false で同じ値が出る★(ズレたらどちらかのバグ)。
 *
 * 用意したケースの一覧は `docs/fixtures.md` §2.6、期待値の固定は
 * `lib/fixtures/__tests__/clean-task-reports.test.ts`。
 */

/**
 * `clean_task_reports` の1行(`prisma/schema.prisma:517-558` の実DDL準拠)。
 *
 * ★`lib/types/index.ts` には置かない★ — あれは ★API のスキーマ名に揃える場所★
 * (`CLAUDE.md` §9.2)であり、これは ★DBテーブルの形★。加えて
 * `lib/types/index.ts` は指示書AQ の担当B の所有ファイルなので触らない。
 */
export interface CleanTaskReportFixture {
  /** ★19桁の数値文字列★(`varchar(20)`)。`cleanTaskReportId()` が作る */
  report_id: string;
  facility_id: number;
  section_id: number;
  /** ★シードは `sections.id` とロボットの id を同値に採番している★(1清掃エリア=1ロボット) */
  robot_id: number;
  /**
   * ★nullable★。★NULL は `IN` に一致せず「未実施」として集計される(落とし穴1)★。
   * ★`0`(`CLEAN_TIMETABLE_ID_UNSCHEDULED`)も予定外のタスク★(TeamWorks 回答 2026-10-27。`cleanTaskReportZeroTimetableRows`)
   */
  clean_timetable_id: number | null;
  serial_number: string;
  /** ★生値(3 / 4 / 5 / 6)。数字のまま持つ★(表示名は画面・CSV が付ける。D-155 項2。6 は Pudu の値で、先方も意味が分からない(第11回 P-4。D-158)。分かれば先方がデータを入れる。数字のまま表示する) */
  status: number;
  /** 清掃対象面積 m²(`double(12,6)`)。★清掃エリアごとに一定★ */
  task_area: number;
  /**
   * 清掃面積 m²(`double(12,6)`)。★清掃ログの完了率から作る★(`runCompletionRate()` で元の完了率に戻る)。
   * ★完了率100の行だけ `task_area` を超える★(上限100の確認)。
   */
  clean_area: number;
  /** 清掃完了率 %(`double(4,1)`)。★完了率には使っていない★(D-155)。清掃ログの完了率と同じ値 */
  clean_ratio: number;
  /** ISO 8601(JST, +09:00) */
  started_at: string;
  /** ★nullable★(`prisma/schema.prisma:541`)。順序キーは `ended_at ?? started_at`(1-4) */
  ended_at: string | null;
  /** 清掃時間(秒)。★0 の行もある★ */
  clean_time: number;
}

// ---------------------------------------------------------------------------
// report_id
// ---------------------------------------------------------------------------

/**
 * `report_id` の先頭。★CSV実測の `1295486100215435264` と同じ19桁の帯★に収まるよう選んだ。
 *
 * ★`BigInt` リテラル(`123n`)は使わない★ — `tsconfig.json` の `target` が `ES2017` のため。
 */
const CLEAN_TASK_REPORT_ID_BASE = BigInt("1295486100000000000");

/** `report_id` の桁数(`varchar(20)` に収まることをテストで固定する) */
export const CLEAN_TASK_REPORT_ID_DIGITS = 19;

/**
 * 清掃(`clean_timetables.id`)から `report_id` を決定的に作る。
 *
 * ★`clean_timetable_id` を NULL にする行でも、report_id は元の清掃 id から作る★ —
 * 「どの清掃の実績か」は当方が分かっていて、★NULL にしているのは join キーだけ★
 * (落とし穴1 の再現)なので、id が衝突しない採番を保てる。
 */
export function cleanTaskReportId(cleanTimetableId: number): string {
  return String(CLEAN_TASK_REPORT_ID_BASE + BigInt(cleanTimetableId));
}

// ---------------------------------------------------------------------------
// status(3 / 4 / 5 / 6)
// ---------------------------------------------------------------------------

/*
 * ★status の規則(分布と巡回列)は `./task-status-rule.ts` が唯一の定義★。
 * ここでは再エクスポートするだけ(`scripts/seed-local-db.ts` と
 * `lib/fixtures/__tests__/clean-task-reports.test.ts` がこのファイルから import しているため)。
 *
 * ★ここに規則を書き戻さないこと★ — `lib/fixtures/clean-logs.ts` の `task_status` も同じ規則を使う。
 * `clean-logs.ts` はこのファイルを import できない(このファイルが読み込み時に `cleanLogs` / `history`
 * を読むため循環 import になる)ので、依存の無い葉モジュールに切り出してある。
 */
export {
  CLEAN_TASK_REPORT_STATUS_CYCLE_LENGTH,
  CLEAN_TASK_REPORT_STATUS_DISTRIBUTION,
  cleanTaskReportStatus,
} from "./task-status-rule";

// ---------------------------------------------------------------------------
// task_area / clean_area
// ---------------------------------------------------------------------------

/** `double(12,6)` に収める(小数第6位まで) */
function round6(value: number): number {
  return Math.round(value * 1_000_000) / 1_000_000;
}

/**
 * ★決定的な疑似乱数★(`lib/fixtures/clean-logs.ts` の `pseudoRandom()` と同じ式)。
 * ★`Math.random()` は使わない★ — 実行のたびに面積が変わると冪等性を確認できない。
 */
function pseudoRandom(seed: number): number {
  const x = Math.sin(seed * 12.9898) * 43758.5453;
  return x - Math.floor(x);
}

const TASK_AREA_MIN_SQM = 600;
const TASK_AREA_STEP_SQM = 25;
const TASK_AREA_STEPS = 17;

/**
 * 清掃エリア(= ロボット)ごとの `task_area`。★同じエリアなら常に同じ値★
 * (実測「あるエリアは19行すべて `878.524992`」に合わせた)。
 *
 * ★既存シードの `TASK_AREA_SQM = 1000`(全エリア一律)とは別物★ —
 * 一律だと「エリアごとに面積が違う」ことに起因する不具合(総清掃面積の按分など)が
 * ローカルでは絶対に出ない。600〜1000 m² の範囲で散らし、小数第6位まで持たせる。
 */
export function cleanTaskReportTaskArea(sectionId: number): number {
  const base = TASK_AREA_MIN_SQM + (sectionId % TASK_AREA_STEPS) * TASK_AREA_STEP_SQM;
  const fraction = ((sectionId * 524_992) % 1_000_000) / 1_000_000;
  return round6(base + fraction);
}

/** 完了率100(上限)の行で `task_area` を超えさせる係数の幅(1.00〜1.40倍) */
const CLEAN_AREA_OVER_TASK_AREA_MAX_EXTRA = 0.4;

/**
 * `clean_area`。★清掃ログの完了率から作る★(設計上の約束3。D-155 / 指示書AS 段階6-1)。
 *
 * - 完了率が上限(100)未満 … `round6(task_area × 完了率 ÷ 100)`
 * - 完了率が上限(100)    … `round6(task_area × 係数)`、係数は `pseudoRandom()` で 1.00〜1.40
 *   (★`task_area` を超える行の再現。`runCompletionRate()` の上限100の確認になる★)
 *
 * ★この作り方で `runCompletionRate({ clean_area, task_area })` が元の完了率に一致する★ —
 *   `lib/fixtures/__tests__/clean-task-reports.test.ts` が全行で確かめている
 *   (2026-10-02 に node で、小数第6位までの `task_area` 200通り × 完了率 0.0〜100.0 の0.1刻みで不一致0件。指示書AS 6-1)。
 * ★帰結★: fixtures の清掃ログに完了率100の行は無い(最大 99.5)ので、★`clean_area > task_area` の行は0件★。
 *   ★CSV の分布(68.3%)の再現はやめた★。
 * ★旧: `CLEAN_AREA_BELOW_TASK_AREA_SHARE`(0.278)で約3割を下回り・約7割を上回りに振り分け、
 *   完了率と独立に作っていた★(旧約束3。D-155 で廃止)。
 */
export function cleanTaskReportCleanArea(
  taskArea: number,
  completionRate: number,
  cleanTimetableId: number
): number {
  if (completionRate < RUN_COMPLETION_RATE.cap) {
    return round6((taskArea * completionRate) / 100);
  }
  const factor =
    1.0 + pseudoRandom(cleanTimetableId * 7 + 3) * CLEAN_AREA_OVER_TASK_AREA_MAX_EXTRA;
  return round6(taskArea * factor);
}

// ---------------------------------------------------------------------------
// ended_at(nullable)/ clean_time
// ---------------------------------------------------------------------------

/** 何件に1件 `ended_at` を NULL にするか(★落とし穴3 の再現★) */
const ENDED_AT_NULL_EVERY = 29;

/**
 * ★`ended_at` を NULL にする行か★(落とし穴3 / 指示書AQ 1-4)。
 *
 * ★判定(◎○△×)には影響しない★ — 面積は入ったままなので、
 * 壊れるとしたら ★「最新1件」の順序キー★ と ★非null を型で要求している箇所★ だけ。
 * そのため境界値ケースの行に当たっても安全で、ブロックを選ばず一律の規則にしてある。
 */
export function cleanTaskReportEndedAtIsNull(cleanTimetableId: number): boolean {
  return cleanTimetableId % ENDED_AT_NULL_EVERY === 0;
}

/** 本清掃の所要時間(分)。`scripts/seed-local-db.ts` の `MAIN_CLEAN_DURATION_MINUTES` と同値 */
export const CLEAN_TASK_REPORT_DURATION_MINUTES = 90;

/** `clean_time` の上限(秒) */
const CLEAN_TIME_MAX_SECONDS = CLEAN_TASK_REPORT_DURATION_MINUTES * 60;

/**
 * `clean_time`(秒)。★`ended_at` が NULL の行は 0★ —
 * 実測に「0 の行」があり、終了時刻が無い行に所要時間だけ入っているのは不自然なため。
 */
export function cleanTaskReportCleanTime(
  cleanTimetableId: number,
  endedAtIsNull: boolean
): number {
  if (endedAtIsNull) return 0;
  const r = pseudoRandom(cleanTimetableId * 13 + 5);
  return Math.round(CLEAN_TIME_MAX_SECONDS * (0.3 + 0.7 * r));
}

// ---------------------------------------------------------------------------
// clean_timetable_id(nullable)★落とし穴1★
// ---------------------------------------------------------------------------

/** 何件に1件 `clean_timetable_id` を NULL にするか */
const NULL_TIMETABLE_ID_EVERY = 37;

/**
 * ★`clean_timetable_id` を NULL にする清掃ログの id★(落とし穴1 の検証用)。
 *
 * ★★対象を `history`(daysBack 6〜30)に限っているのは意図★★ —
 * NULL の行は ★行が消えるのではなく「未実施」として集計される(fail-silent)★ ので、
 * `boundaryCases` / `escalationCases` / `statusCases` に当てると
 * ★`docs/fixtures.md` §2.1 の境界値10ケースと §2.2 のエスカレーションが壊れ、
 * 他担当が「90.0 が ◎ になる」ことを画面で確かめられなくなる★。
 *
 * ★`scripts/seed-local-db.ts` はこの Set をそのまま import して使う★ので、
 * ★ローカルDBと fixtures で NULL になる行が完全に一致する★。
 *
 * ★★既知の構造上の制約(意図した非対称。直さないこと)★★ —
 * この Set の清掃は、★DB 経由(`USE_FIXTURES=false`)では「出発失敗・`task_status` null」★に、
 * ★fixtures 経由(`cleanLogs`)では「実施済み・`task_status` あり」★に見える。
 *   - DB 経由: `clean_timetable_id` が NULL の行は `IN` に一致しないので、対応するレポート行が
 *     「無い」扱いになり、指示書AQ 1-1 により未実施(=出発失敗と同一扱い)になる(落とし穴1 そのもの)。
 *   - fixtures 経由: `cleanLogs` の `departed` / `completion_rate` / `task_status` は
 *     `clean-logs.ts` の入力から決まり、このファイルの結果を見ない。
 * ★fixtures 側を DB 経由に合わせられない理由★: この Set は `completion_rate !== null` の行から作っている。
 *   `cleanLogs` 側でその行を未実施(`completion_rate: null`)にすると、★Set に入る行が無くなり(空になり)、
 *   落とし穴1 を再現するケースそのものが消える★。
 * ★したがって、この数行について2経路の画面表示が違うのは不具合ではない★(`docs/fixtures.md` §2.6)。
 * `lib/fixtures/__tests__/areas.test.ts` の一致テストは `report_id`(元の清掃 id から作る)で突き合わせるため、
 * この数行でも `task_status` と `status` は一致する。
 */
export const cleanTaskReportNullTimetableLogIds: ReadonlySet<number> = new Set(
  history
    .filter((log) => log.completion_rate !== null && log.id % NULL_TIMETABLE_ID_EVERY === 0)
    .map((log) => log.id)
);

// ---------------------------------------------------------------------------
// clean_timetable_id = 0(予定外のタスク。TeamWorks 回答 2026-10-27)
// ---------------------------------------------------------------------------

/**
 * ★`clean_timetable_id = 0` の行の `report_id` に使う番号の下駄★。
 * 清掃ログ由来の `report_id`(`cleanTaskReportId(log.id)`。`log.id` は 1〜99,999。`scripts/seed-local-db.ts` の
 * 採番規則)と ★重ならない帯★にする。
 */
const ZERO_TIMETABLE_REPORT_ID_OFFSET = 9_000_000;

/** 予定外のタスクを、元にした清掃ログの開始から何分後に始めたことにするか(★同じ夜の追加の清掃★) */
const ZERO_TIMETABLE_START_AFTER_MINUTES = 180;

/** 予定外のタスクの完了率(★◎○△× の境界に当たらない値★。完了率・実施回数には入らないので判定は出ない) */
const ZERO_TIMETABLE_COMPLETION_RATE = 55.5;

/**
 * ★`clean_timetable_id` が `0` の行★(予定外のタスク。TeamWorks 回答 2026-10-27)。
 *
 * ★清掃ログ(`cleanLogs`)に対応しない、追加の1行★ — NULL の行(`cleanTaskReportNullTimetableLogIds`)は
 *   既存の清掃ログの join キーを NULL にして作ったが、★こちらは既存の行を変えずに足す★
 *   (境界値・エスカレーション・他のテストの期待値を動かさないため)。
 *
 * ★期待する扱い★(`lib/db/timetable-link.ts` の `scheduledTimetableId()`):
 *   - ★総清掃面積(F-08)の面積の合計には入る★(予定外も実際に清掃した広さ。D-155)
 *   - ★完了率・実施回数には入らない★/★重複の番人(`countDuplicatedTimetables()`)は数えない★
 *   - ★`IN` で予定から引くクエリには返って来ない★(予定の id に 0 は無い)
 *
 * ★元にする清掃ログ★: `history` のうち実施済み・NULL 対象でない最初の行(★施設・清掃エリア・ロボットだけ借りる★)。
 *   ★`scripts/seed-local-db.ts` はこの配列をそのまま投入する★(`USE_FIXTURES` の true/false で同じ行)。
 *   ★fixtures 版の画面(`USE_FIXTURES=true`)はこのファイルを読まない★(総清掃面積は `areaCompletions` から作る)。
 *
 * ★配列そのもの(`cleanTaskReportZeroTimetableRows`)はファイル末尾で作る★ — `robotBySerial` より前で呼ぶと
 *   初期化前の参照で落ちるため。
 */
function buildZeroTimetableRows(): CleanTaskReportFixture[] {
  const base = history.find(
    (log) => log.completion_rate !== null && !cleanTaskReportNullTimetableLogIds.has(log.id)
  );
  if (!base) {
    throw new Error("[fixtures/clean-task-reports] clean_timetable_id=0 の行の元にする清掃ログが history にありません");
  }
  const robot = robotBySerial.get(base.serial_number);
  if (!robot) {
    throw new Error(
      `[fixtures/clean-task-reports] serial=${base.serial_number}(clean_log id=${base.id})に対応するロボットが lib/fixtures/facilities.ts にありません`
    );
  }
  const seed = ZERO_TIMETABLE_REPORT_ID_OFFSET + base.id;
  const taskArea = cleanTaskReportTaskArea(robot.robotId);
  const startedAt = addMinutesJstIso(base.scheduled_at, ZERO_TIMETABLE_START_AFTER_MINUTES);
  return [
    {
      report_id: cleanTaskReportId(seed),
      facility_id: base.facility_id,
      section_id: robot.robotId,
      robot_id: robot.robotId,
      // ★`0` を直書きしない★(`CLEAN_TIMETABLE_ID_UNSCHEDULED`)
      clean_timetable_id: CLEAN_TIMETABLE_ID_UNSCHEDULED,
      serial_number: base.serial_number,
      // ★清掃正常終了★(status の巡回列に入れない。`CLEAN_TASK_REPORT_STATUS_DISTRIBUTION` の数を変えないため)
      status: 4,
      task_area: taskArea,
      clean_area: cleanTaskReportCleanArea(taskArea, ZERO_TIMETABLE_COMPLETION_RATE, seed),
      clean_ratio: ZERO_TIMETABLE_COMPLETION_RATE,
      // ★予定外のタスクの `started_at` は実際に清掃を始めた時刻★(`CLAUDE.md` §4.7)
      started_at: startedAt,
      ended_at: addMinutesJstIso(startedAt, CLEAN_TASK_REPORT_DURATION_MINUTES),
      clean_time: cleanTaskReportCleanTime(seed, false),
    },
  ];
}

// ---------------------------------------------------------------------------
// 組み立て
// ---------------------------------------------------------------------------

/**
 * シリアル → `{ facility_id, robot_id }`。
 * ★シードと同じ規則★(`section_id` はロボットの id と同値。1清掃エリア=1ロボット)。
 */
const robotBySerial = new Map<string, { facilityId: number; robotId: number }>();
for (const facility of facilities) {
  for (const robot of facility.robots) {
    robotBySerial.set(robot.serial_number, {
      facilityId: facility.id,
      robotId: robot.id,
    });
  }
}

/**
 * ISO 8601(JST)の時刻に分を足す。★実行環境の TZ に依存させない★
 * (`getUTC*` で組み立てる。`scripts/seed-local-db.ts` の `toDbJst()` と同じ考え方)。
 */
function addMinutesJstIso(iso: string, minutes: number): string {
  const shiftedJstMs = new Date(iso).getTime() + minutes * 60_000 + 9 * 60 * 60 * 1000;
  const d = new Date(shiftedJstMs);
  const p = (n: number): string => String(n).padStart(2, "0");
  return (
    `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}` +
    `T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}+09:00`
  );
}

function build(): CleanTaskReportFixture[] {
  const out: CleanTaskReportFixture[] = [];
  for (const log of cleanLogs) {
    // ★未実施=行を作らない(指示書AQ 1-1)★。`clean_error` 相当の列が無いので行の有無が唯一の根拠
    if (log.completion_rate === null) continue;

    const robot = robotBySerial.get(log.serial_number);
    if (!robot) {
      // ★fail-loud★: 実施済みなのにロボットが引けないのは fixtures 側の不整合。
      //   (未実施の行だけは施設12=ロボット0台があるので、上の `continue` で先に抜けている)
      throw new Error(
        `[fixtures/clean-task-reports] serial=${log.serial_number}(clean_log id=${log.id})に対応するロボットが lib/fixtures/facilities.ts にありません`
      );
    }

    const endedAtIsNull = cleanTaskReportEndedAtIsNull(log.id);
    const taskArea = cleanTaskReportTaskArea(robot.robotId);

    out.push({
      report_id: cleanTaskReportId(log.id),
      facility_id: log.facility_id,
      section_id: robot.robotId,
      robot_id: robot.robotId,
      clean_timetable_id: cleanTaskReportNullTimetableLogIds.has(log.id) ? null : log.id,
      serial_number: log.serial_number,
      status: cleanTaskReportStatus(log.id),
      task_area: taskArea,
      // ★清掃ログの完了率から作る★(設計上の約束3。`runCompletionRate()` で元の完了率に戻る)
      clean_area: cleanTaskReportCleanArea(taskArea, log.completion_rate, log.id),
      // ★`clean_ratio` 列は完了率に使っていない。列に戻すときのために、清掃ログの完了率と同じ値を入れてある★
      //   (設計上の約束2。指示書AS D6)
      clean_ratio: log.completion_rate,
      started_at: log.scheduled_at,
      ended_at: endedAtIsNull
        ? null
        : addMinutesJstIso(log.scheduled_at, CLEAN_TASK_REPORT_DURATION_MINUTES),
      clean_time: cleanTaskReportCleanTime(log.id, endedAtIsNull),
    });
  }
  return out;
}

/**
 * `clean_task_reports` のサンプルデータ。
 * ★`cleanLogs` と同じ並び(`started_at` 降順)★。
 * ★清掃ログ由来の行だけ★ — `clean_timetable_id = 0` の追加の行(`cleanTaskReportZeroTimetableRows`)は含めない
 *   (この配列は「実施済みの清掃1回につき1行」をテストで固定しているため)。
 */
export const cleanTaskReports: CleanTaskReportFixture[] = build();

/**
 * ★`clean_timetable_id` が `0` の行★(予定外のタスク。TeamWorks 回答 2026-10-27)。
 * 作り方・期待する扱いは `buildZeroTimetableRows()` の docstring。
 */
export const cleanTaskReportZeroTimetableRows: readonly CleanTaskReportFixture[] = buildZeroTimetableRows();