/**
* データソースの切替
*
* 環境変数 `USE_FIXTURES` で「サンプルデータ」と「実DB(Prisma)」を切り替えます。
*
* USE_FIXTURES=true → lib/fixtures/ のサンプルデータを返す(DB不要)
* USE_FIXTURES=false → Prisma経由で実DBを読む
*
* ★画面側のコードは切替を意識しません。APIルートがこのモジュールを呼ぶだけです★
*
* ★判定ロジック(lib/judge/)はこの切替の外側に置くこと★
* fixture開発中も本番と同じ判定ロジックが走るようにするためです(D-044)。
*
* 【重要】メインDBは原則読み取り専用です。
* 書き込んでよいのは robot_error_reports / control_error_histories の
* status / comment / responded_at / resolved_at の4カラムのみ(D-042)。
* ★書き込みは `lib/db/robot-error-write.ts` の `writeRobotErrorColumns()` だけ★(指示書AT)。
*/
import type {
AreaCompletionDbQuery,
AreaCompletionRow,
CleanAreaDbQuery,
CleanLogDbQuery,
CleanLogItem,
CleanLogRunMeta,
ContinuousProblemRow,
Customer,
DsAlert,
DsDevice,
ErrorBreakdownRow,
ErrorReportDbQuery,
ErrorReportItem,
Facility,
FacilityGroupRef,
FacilityNameRef,
FacilityPerformance,
FacilityPerformanceDbQuery,
FacilityPerformanceRow,
Group,
InspectionDetail,
InspectionListItem,
PrecheckDbQuery,
PrecheckItem,
PrecheckStatusPatch,
ReportDbQuery,
ReportKpi,
ReportKpiDbQuery,
ReportKpiRow,
ReportPeriodKey,
RescueDbQuery,
RescueRequiredItem,
Schedule,
} from "@/lib/types";
import {
areaCompletions,
cleanLogs,
continuousProblems,
customers,
dsAlerts,
dsDevices,
errorBreakdown,
errorReportRows,
errorReportTimetables,
facilities,
facilityPerformance,
groups,
inspectionDetails,
inspections,
prechecks,
reportKpis,
schedules,
} from "@/lib/fixtures";
import { CLEAN_TIMETABLE_PRECHECK, MAKER_NAMES } from "@/lib/config";
import { judgeEscalationLevel, judgeFacilityStatus, judgeGrade } from "@/lib/judge";
import {
completionRateFromReport,
computeConsecutiveProblemDays,
formatModelName,
isFutureSchedule,
latestReportByTimetableId,
notExecutedFromReport,
robotStatusFromErrors,
type ConsecutiveInput,
type RobotErrorLike,
} from "@/lib/db/clean-log-mapping";
import { chunkForInClause } from "@/lib/db/chunked";
import { isResultPending } from "@/lib/clean-logs/result-pending";
// ★清掃面積(指示書AN 段階3)★ — ★ロールアップ(`aggregateCleanArea()`)はここでやらない★。
// 材料(施設ごとの合計 + 施設マスタ + 番人の件数)を返すだけにして、
// ★粒度(`group_by`)ごとの組み立てはルート層に任せる★(同じ材料から3粒度とも作れる)。
// ★★`groupBy` はここに直書きせず、同ファイルの2関数を必ず経由する★★(指示書AQ §5-3)—
// ★番人の `clean_timetable_id IS NOT NULL` 除外があちらにしか無い★ため。
import {
countDuplicatedTimetables,
loadCleanAreaFacilityMasters,
loadCleanAreaFacilityStatsWithRowCount,
type CleanAreaSourceResult,
type CleanAreaWhere,
} from "@/lib/db/clean-area-source";
import { cleanAreaStatsFromFixtureAreas } from "@/lib/db/fixture-clean-area";
import {
loadConsecutiveInputs,
type ConsecutiveDbRange,
} from "@/lib/db/consecutive-source";
import { dbDateRange } from "@/lib/db/date-range";
import {
buildRescueRequired,
rescueInputsFromCleanLogs,
} from "@/lib/db/rescue-mapping";
import { loadFacilityGroupIndex } from "@/lib/db/facility-group-source";
import { loadFacilityNameIndex } from "@/lib/db/facility-name-source";
import { filterFixtureAreaCompletions } from "@/lib/db/fixture-area-completions";
import { filterFixtureCleanLogs } from "@/lib/db/fixture-clean-logs";
import { filterFixturePrechecks } from "@/lib/db/fixture-prechecks";
import {
applyFixturePrecheckPatch,
withFixturePrecheckOverride,
} from "@/lib/db/fixture-precheck-overrides";
import {
applyFixtureErrorReportPatch,
withFixtureErrorReportOverride,
} from "@/lib/db/fixture-error-report-overrides";
import {
errorReportItemFromRow,
errorReportKeyFromRow,
errorReportListWhere,
type ErrorReportKey,
type ErrorReportRowSource,
type ErrorTimetableRef,
} from "@/lib/db/error-report-mapping";
import { filterSortErrorReportKeys } from "@/lib/error-reports/query";
import { writeRobotErrorColumns } from "@/lib/db/robot-error-write";
import {
findTimetableById,
findTimetables,
findTimetablesByChunks,
type TimetableFindManyArgs,
} from "@/lib/db/timetable-source";
import {
ROBOT_ERROR_COUNT_SELECT,
ROBOT_ERROR_LIST_KEY_SELECT,
ROBOT_ERROR_LIST_ROW_SELECT,
ROBOT_ERROR_PRECHECK_ROW_SELECT,
ROBOT_ERROR_STATUS_SELECT,
ROBOT_ERROR_TYPE_SELECT,
} from "@/lib/db/robot-error-select";
import {
filterFixtureFacilityPerformance,
filterFixtureReportKpis,
filterFixtureReportRows,
filterFixtureRowsByFacility,
} from "@/lib/db/fixture-reports";
import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids";
// ★fixtures の屋号一覧を `id` 昇順にするため★(指示書AL 段階3 §4-4)—
// ★並べ替え規則の「正」は `sortCustomers()` の1本★。ここに比較関数を写さない。
import { sortCustomers } from "@/lib/masters/query";
import {
mainRunSucceededFromReport,
PRECHECK_COLUMN_COUNT,
precheckItemFromErrorRow,
precheckMinutesFromCode,
statusColumnsFromPrecheckRows,
} from "@/lib/db/precheck-mapping";
import { logEvent } from "@/lib/logger";
import {
connectionStatusFromControlStatus,
resolutionStatusFromControlErrorStatus,
} from "@/lib/db/ds-status";
import {
aggregateCleanStatsByFacility,
consecutiveProblemDaysAtPeriodEnd,
continuousProblemsInPeriod,
countMainErrorTypesInRange,
customerSummariesByPeriodKey,
errorBreakdownItemsFromCounts,
errorBreakdownRowsFromSummary,
errorCountByMainTimetable,
kpiFromCleanStats,
kpiFromSummaryRatios,
mainTimetableStartAtById,
periodMapKey,
periodOfSummaryRow,
sumCleanStatsBuckets,
} from "@/lib/db/report-mapping";
import { formatAddress } from "@/lib/format/address";
import { parseContract } from "@/lib/format/contract";
import { fromDbJst, jstIsoFromDb } from "@/lib/format/date";
// ★`customers.group_id` の 0 → null 変換はこの1本だけが知っている★(D-011 / 指示書AK §4-4)—
// ★`=== 0` を `prismaSource` の中に書かないこと★(落とし穴3)。
import { groupIdFromDb } from "@/lib/format/group-id";
// ★`todayJst()` / `weekOfMonth()` はもうここでは使わない★(指示書AC 段階5)—
// ★当期かどうかの判定は `isCurrentPeriod()` に、期間の範囲は `periodRange()` に任せる★。
// ★★「今日」から期間キーを自分で組み立てないこと★★(月跨ぎ週の帰属を知っているのは
// `weekOfMonth()` だけ。`CLAUDE.md` §4.7 / D-071)。
import { isCurrentPeriod, periodRange, shiftPeriod } from "@/lib/format/period";
import {
formatEndTime,
formatStartTime,
weekdaysLabel,
} from "@/lib/format/schedule";
export { USE_FIXTURES } from "@/lib/db/use-fixtures";
import { USE_FIXTURES } from "@/lib/db/use-fixtures";
/**
* データソースが実装すべきインターフェース。
* フィルタや集計はこの層では行わず、APIルート側で組み立てます
* (実DB接続時にクエリへ落とすときの差分を小さくするため)。
*
* ★例外: レポート4メソッドの「当期(進行中の週・月)」だけは `prismaSource` の中で
* 詳細テーブルからその場集計する(指示書T 段階2。D-090項14/項15の解決)★ —
* `cleanLogs()` の連続日数・judge 呼び出しと同じ扱い
* (D-090項16「judgeをprismaSourceの中で呼ぶこと自体は妥当」)。
* 確定期間(サマリテーブル由来)はこれまでどおり再計算しない(D-102 / CLAUDE.md 禁止事項1)。
* ★★2026-09-13(段階5)以降は「どの期間を集計すべきか」を `ReportDbQuery` で
* 受け取る★★ので、★当期かどうかの判定も期間の範囲もその引数から導く★
* (`todayJst()` は当期判定(`isCurrentPeriod()`)の中だけで使う)。
*
* ★★2026-09-13(指示書AC 段階2 / ★段階3★ / ★段階4★ / ★段階5★):
* `inspectionDetail()` 以外の主要メソッドは引数を取るようになりました★★ —
* ★「全部返す」しかできない契約では本番のデータ量(保持2年で約26万行)に耐えない★
* ため、★日付範囲・施設ID・単一ID・期間 を SQL に降ろします★。
* ★絞り込み規則の「正」は API ルート側の純粋関数(`filterCleanLogs()` /
* `selectAreaRowsForLog()` 等)に置いたまま★ —
* ★SQL 側はその写し★ です(指示書AC C-5。★だから `filterXxx()` を外さないこと★)。
*/
export interface DataSource {
/**
* 清掃詳細ログの一覧。
*
* ★★引数は必須★★(指示書AC C-4 項4)— ★全件が要る呼び出し側は「範囲なし」
* (`{ date_from: null, date_to: null, facility_ids: null }`)を明示的に渡すこと★。
* ★任意引数にすると「渡し忘れても動く(が全件読む)」状態が残り、
* 直したつもりで直っていないことに誰も気づけない★。
*
* ★★`facility_ids` の `null` と `[]` は別物★★(`CleanLogDbQuery` の型コメント)—
* `null` = 施設で絞らない / `[]` = ★0件★。
*/
cleanLogs(query: CleanLogDbQuery): Promise<CleanLogItem[]>;
/**
* ★清掃ログの id → { 清掃エリア, 予定の終了 }★(ダッシュボードの直近モード専用。D-159 / 指示書AV)。
* ★`cleanLogs()` と同じ引数を渡すこと★(同じ `where`。突き合わせは `pickLatestRunPerSection()`)。
* ★`cleanLogs()` が返さない id(未来の予定)を含んでよい★。逆(`cleanLogs()` の行に対応が無い)は呼び出し側の例外
*
* ★★`CleanLogItem` にこの2列を足さずに別メソッドにした理由★★(指示書AV §2 D9): `CleanLogItem` は
* 多くの経路が共有しており、内部用の列を足すと剥がし忘れた瞬間に API に漏れるため。
*/
cleanLogRunMeta(query: CleanLogDbQuery): Promise<CleanLogRunMeta[]>;
/**
* ★施設名 → 施設ID の解決★にだけ使う、施設マスタの最小限の一覧(指示書AC 段階2 D-6)。
*
* ★★`facilities()` と用途が違う★★ — あちらは施設マスタ画面(F-05)に返す一覧で
* ★`deleted_at` で絞っている★。こちらは ★絞らない★(★罠7★。理由は
* `loadFacilityNameIndex()`(`lib/db/facility-name-source.ts`)の docstring)。
* ★★`facilities()` で代用しないこと★★ — 論理削除された施設のログが
* 検索から静かに消えます。
*/
facilityNameIndex(): Promise<FacilityNameRef[]>;
/**
* ★分類(ホールディングス/屋号)→ 施設ID の解決★にだけ使う、施設マスタの最小限の一覧
* (指示書AK 段階3 §6-3)。
*
* ★★`facilityNameIndex()` と同じ立ち位置★★ — ★`facilities()` で代用しないこと★
* (あちらは `deleted_at` で絞っている。★罠7★。理由は
* `loadFacilityGroupIndex()`(`lib/db/facility-group-source.ts`)の docstring)。
*
* ★★`facilityNameIndex()` に列を足す形にしないこと★★ — 用途が違う2つの読み取りを
* 1本にまとめると、片方の都合で `where` や列を変えたときに
* ★もう片方の要件を黙って壊します★(`dsDeviceExists()` を `dsDevices()` に
* 足さなかったのと同じ判断)。
*
* ★解決(当たり外れ)は `lib/facilities/resolve-by-master.ts` の純粋関数★。
* ★このメソッドは行を読むだけ★。
*/
facilityGroupIndex(): Promise<FacilityGroupRef[]>;
/**
* エリア別完了率の行。
*
* ★★引数は必須★★(指示書AC 段階3 / C-4 項4。`cleanLogs()` と同じ作法)—
* ★全件が要る呼び出し側は「範囲なし」(`{ facility_id: null, date: null }`)を
* 明示的に渡すこと★。★以前は引数を取らず**全期間・全施設**を読んでいた★
* (1行の表示のために実測 1,582行。指示書AC B-3)。
*
* ★★`filter` の適用と全体率の計算は APIルート側のまま★★
* (`lib/clean-logs/areas.ts` の `selectAreaRowsForLog()` / `buildAreaCompletions()`)—
* ★SQL 側は `selectAreaRowsForLog()` の写し★ です(C-5)。
*/
areaCompletions(query: AreaCompletionDbQuery): Promise<AreaCompletionRow[]>;
/**
* アラート(事前チェック)一覧。
*
* ★★引数は必須★★(指示書AC 段階4 / C-4 項4。`cleanLogs()` と同じ作法)—
* ★全件が要る呼び出し側は「範囲なし」(`{ date_from: null, date_to: null,
* facility_ids: null }`)を明示的に渡すこと★。
*
* ★★`facility_ids` の `null` と `[]` は別物★★(`cleanLogs()` と同じ契約。★罠1★)—
* `null` = 施設で絞らない / `[]` = ★0件★(分類で絞って1件も当たらなかった)。
*
* ★★2種類の行(`precheck` / `launch_failed`)を**同じ期間**で絞る★★ —
* ★基準はどちらも「本清掃行の `start_at`」★(`PrecheckDbQuery` の型コメント)。
* ★★片方だけ絞れている状態を検出するため、`severity` の内訳をテストで固定してある★★
* (`app/api/prechecks/__tests__/route.test.ts` の #3)。
*
* ★★対応状況(`status`)の絞り込みは APIルート側のまま★★
* (`filterPrechecksByStatus()` / `sortPrechecks()`。指示書AC C-5)。
*/
prechecks(query: PrecheckDbQuery): Promise<PrecheckItem[]>;
/**
* `PATCH /prechecks/{id}/status` の前後の読み取り(指示書AT 段階4-2 / §3-3)。★参照用の接続で読む★。
*
* `{id}` を `robot_error_reports.error_id` として引き、★次をすべて満たすときだけ★行を返す:
* 1. `error_id = id` の行がある(★大小文字も含めて JS の `===` で一致★。DB の照合順序は ci。D-4)
* 2. その行の `clean_timetable_id` が null でない
* 3. その `clean_timetables` 行があり、`precheckMinutesFromCode(precheck)` が null でない(=事前チェック行)
* 4. その事前チェック行の `parent_id` が指す本清掃行がある(★無ければ例外=500★。`prechecks()` と同じ)
* ★1〜3 のどれかを満たさなければ `null`(=404)★。★出発失敗の id(`clean_timetables.id` の文字列)は
* `error_id` として引いても当たらないので `null`★(別の分岐を作らない)。
* ★期間・施設では絞らない★(一覧の絞り込みであって行の性質ではない)。
* ★行の組み立ては `precheckItemFromErrorRow()`★(`prechecks()` と同じ関数)。
* ★`main_run_succeeded`(D-171)は 4. の本清掃行の `clean_task_reports` から決める★(無ければ false。404 にはしない)。
*/
precheckItemById(id: string): Promise<PrecheckItem | null>;
/**
* `robot_error_reports` の4カラムを `updateMany` の1文で書く(指示書AT §3-2 / 段階4-2)。
* ★書き込み用の接続(`getWritePrisma()`)で書く★。中身は `writeRobotErrorColumns()`
* (`lib/db/robot-error-write.ts`)の1本。
*
* ★`matched` で存在を判定しないこと★ — 存在確認は書く前に `precheckItemById()` で行う。
* 0 なら「確認と書き込みの間に行が消えた」(ルートが 404 にする)。
*/
updatePrecheckErrorColumns(errorId: string, patch: PrecheckStatusPatch): Promise<{ matched: number }>;
/**
* エラー一覧(`GET /errors`。指示書AW)。★`robot_error_reports` の全行★が対象(事前チェック・本清掃・予定なし・`robot_id` NULL を問わない)。
* ★2段階で読む★(D8): キーだけを SQL で `status IN`・`facility_id IN`・期間で絞って読み、
* `filterSortErrorReportKeys()` で絞り込み・並べ替え → ページの `error_id` だけ列を足して読む。
* ★`total` は絞り込み後・ページ分け前の件数★(`meta.total`)。
*/
errorReports(query: ErrorReportDbQuery): Promise<{ items: ErrorReportItem[]; total: number }>;
/**
* `PATCH /errors/{error_id}/status` の前後の読み取り。★`error_id === id` の行があれば返す(事前チェックかどうかを問わない)★。
* ★大小文字は JS の `===` で決める★(D-4。`precheckItemById()` と同じ)。★期間・施設・状態では絞らない★
*/
errorReportById(id: string): Promise<ErrorReportItem | null>;
/**
* 4カラムを書く(`writeRobotErrorColumns()` の1本。★書き込み用の接続★)。
* ★`matched` で存在を判定しないこと★ — 存在確認は書く前に `errorReportById()` で行う
*/
updateErrorReportColumns(errorId: string, patch: PrecheckStatusPatch): Promise<{ matched: number }>;
dsDevices(): Promise<DsDevice[]>;
/**
* ★備考の保存(`PATCH /ds-devices/{serial_number}/note`)の実在確認★だけに使う
* (指示書AH 段階2)。
*
* ★★`dsDevices()` に引数を足す形にしないこと★★ — ★一覧側はサマリ(`summarizeDsDevices()`)
* のために全件が要る★(D-054。`summary.total` は q / status フィルタの影響を受けない)。
* ★`facilityNameIndex()` が前例★(「`facilities()` と用途が違うから別メソッドにした」)。
*
* ★★`deleted_at` の絞りを `dsDevices()` と揃えること★★(★罠7★)—
* 絞りをやめると ★一覧に出ないデバイスの備考が保存できてしまい、どこからも参照されない
* 行がストアに残る★。★それはこのチェックが防いでいる当のもの★。
*
* ★★当たり外れは呼び出し先の実装が JS の `===` で決める★★(D-4)—
* ★DBの既定の照合順序は大小文字を区別しない(ci)★ので、SQL の一致をそのまま
* 答えにすると `ds-001` が `DS-001` に当たって 404 が 200 になる。
* ★保存キーはリクエストの生文字列★(`lib/settings/ds-device-notes.ts` は
* `Record<string, string>`)で、★一覧は `notes["DS-001"]` で引く★ため、
* ★★保存されたのに一生表示されない孤児行が残る★★。
*/
dsDeviceExists(serialNumber: string): Promise<boolean>;
dsAlerts(): Promise<DsAlert[]>;
/**
* ホールディングス一覧(`GET /groups`。指示書AK 段階1)。
*
* ★`deleted_at IS NULL` でのみ絞る★(`facilities()` と揃える)。
* ★`inactive` では絞らない★ — 無効化は削除ではなく、画面が見せ分ける可能性がある
* (`facilities()` :1300-1302 と同じ判断)。
*
* ★★引数を取らない★★ — 分類マスタは数十行の規模で、絞り込む対象が無い
* (`facilities()` / `schedules()` と同じ扱い)。
* ★`id = 0` の行はそもそも存在しない★(0 は `customers.group_id` 側のセンチネル。D-011)。
*/
groups(): Promise<Group[]>;
/**
* 屋号一覧(`GET /customers`。指示書AK 段階1)。
*
* ★`group_id` の絞り込みは API ルート側の純粋関数★(`filterCustomersByGroup()`)—
* ★分類マスタは数十行なので SQL に降ろす利得が無い★(清掃ログの26万行とは事情が違う。
* 指示書AC C-4 の「引数必須」はデータ量が理由)。★絞り込み規則を1箇所に置く★ほうを採る。
*
* ★★`customers.contract` を読まないこと★★(D-010 / `CLAUDE.md` §7。落とし穴5)—
* 削除依頼済みの列で、契約フラグは `facilities.contract` のみが正。
*
* ★`group_id` は 0 → `null` に変換して返す★(D-011)。
* 変換を知っているのは `groupIdFromDb()`(`lib/format/group-id.ts`)だけ。
*
* ★★`id` の昇順で返すこと★★(指示書AL ★段階3★ / §4-4。`groups()` と違う点)—
* `GET /customers` はルート側の `sortCustomers()` で並べ替えるので順序に依存しないが、
* ★`GET /reports/kpi` の 422 は `db.customers()` の並びをそのまま
* `details.customer_ids` に流す★(`resolveTargetCustomer()` は並べ替えない)。
* 順序をDB任せにすると ★画面に出る候補の並びが環境で変わる★。
*/
customers(): Promise<Customer[]>;
facilities(): Promise<Facility[]>;
schedules(): Promise<Schedule[]>;
inspections(): Promise<InspectionListItem[]>;
inspectionDetail(id: number): Promise<InspectionDetail | null>;
/**
* 要レスキュー店舗(F-01 の `GET /dashboard` の `rescue_required`。指示書AI 段階4)。
*
* ★★引数は必須★★(指示書AC C-4 項4。`cleanLogs()` と同じ作法)。
*
* ★★`cleanLogs()` で代用しないこと★★ — 3条件のうち
* ★R-1(未実施3日連続)と R-3(前回比の急落)は当日の行だけでは判定できない★し、
* ★★当該日に予定が無い施設も対象に含める★★(ユーザー指示 2026-09-15 / 指示書AI §1-5)ので
* 母集団も違う。★`CleanLogItem` には前回比も未実施連続日数も入っていない★。
*
* ★★数値カード(`summary`)には影響しない★★ — あちらは当日のログだけから作る
* (`lib/dashboard/aggregate.ts`。★変更しない★)。
* ★★ただし分類の絞り込み(`facility_ids`)は両方に同じ配列を渡すこと★★
* (指示書AK §7-2)— ★母集団がずれると、数値カードと要レスキューが
* 別の集団の話になる(例外もテスト失敗も出ない)★。
*
* ★入力は `loadConsecutiveInputs()` の1本から取る★(★新しい全期間クエリを書かないこと★。
* D-109 の再発防止。要レスキューは4本目の呼び出し元になる)。
*/
rescueRequired(query: RescueDbQuery): Promise<RescueRequiredItem[]>;
/**
* 清掃面積の集計材料(`GET /clean-area`。指示書AN ★段階3★ / §6-1)。
*
* ★★引数は必須★★(指示書AC C-4 項4。`cleanLogs()` と同じ作法)—
* ★全期間が要る呼び出し側は「範囲なし」を明示的に渡すこと★。
*
* ★★集計材料を返すだけ★★ — ★ロールアップ(`aggregateCleanArea()`。段階2 の純粋関数)は
* 呼ばない★。理由は ★粒度(`group_by`)がリクエストの引数★で、
* ★同じ材料から group / customer / facility の3粒度とも作れる★ため
* (粒度をここで受け取ると、粒度ごとに同じ集計クエリを撃ち直す形に誘導してしまう)。
* ★判定・集計をデータ層に持ち込まない★という既存の分担(`filterXxx()` / `sortXxx()` が
* ルート側にあるのと同じ。指示書AC C-5)にも合う。
*
* ★★サマリテーブルの面積を読まないこと★★(指示書AN §1-2 / 落とし穴1)—
* `facility_summaries` / `customer_summaries` の `task_area` は
* ★実データで全行 1000 固定★で、★期間内の合計になっていない★(第9回 Q2-1 で照会中)。
* ★D-102(サマリは再計算しない)の対象は KPI 4指標で、面積は対象外と読む★(指示書AN §1-2)。
*
* ★★戻り値の `duplicated_timetable_count` を握りつぶさないこと★★(★番人★。§2-5 / §6-3)—
* ★ルートは必ず `reportCleanAreaAnomalies()`(`lib/clean-area/anomaly.ts`)に渡すこと★。
* ★渡し忘れても例外もテスト失敗も出ない★(数字が静かに大きくなるだけ)。
*/
cleanArea(query: CleanAreaDbQuery): Promise<CleanAreaSourceResult>;
/**
* レポートの4メソッド。
*
* ★★引数は必須★★(指示書AC ★段階5★ / C-4 項4。`cleanLogs()` と同じ作法)—
* ★以前は引数を取らず**全期間・全件**を読んでいた★(4API 合計で ★実測 7,587行★。
* 指示書AC B-3 の約6,000行に、段階1 で分けた連続日数の1本が加わった値)。
* ★★レポート画面は必ず1期間を選んでいる★★ので、★「範囲なし」を表す値は用意しない★。
*
* ★★確定期間と当期で読むものが違う★★(`CLAUDE.md` §4.7):
* - ★確定期間★ … サマリテーブルを `where: { year, month, week }` で引く。
* ★★履歴から再計算しない(D-102 / `CLAUDE.md` 禁止事項1)★★
* - ★当期(進行中の週・月)★ … サマリに行が無いのが正常なので、詳細テーブルを
* ★`periodRange()` が返す範囲★で絞ってその場集計する
*
* 戻り値が `Xxx[]` ではなく `XxxRow[]`(期間キー付きの行型)なのは、
* ★ルート側の `selectXxxRows()` / `selectKpiRow()`(=絞り込み規則の「正」。C-5)が
* そのキーで照合するため★です(`ReportPeriodKey` のコメント参照)。
* ★★`filterXxx()` / `sortXxx()` と同じく、あちらを外さないこと★★。
*/
/**
* レポートKPI。★前期比のため1つ前の期間のサマリも読む(2行ぶんだけ)★
*
* ★★このメソッドだけ絞り込みの軸が2つある★★:
* - ★確定期間★ … `customer_summaries`(★施設ではなく**顧客**単位★)を読むので
* ★`customer_id`★ で絞る(指示書AK ★段階6★ / §2-1。`ReportKpiDbQuery`)
* - ★★当期(進行中の週・月)★★ … 詳細テーブルをその場集計するので
* ★`facility_ids`★ で絞る(指示書AL ★段階1★。★以前はここを絞っておらず、
* ★★確定期間は1社・当期は全社合算★★ という食い違いが起きていた。D-138 の残課題)
*
* ★★2つの軸は同じ顧客を指していなければならない★★ — ★`facility_ids` は
* `customer_id` と同じ顧客から解決したものを渡す契約★(ルート側で解決する。
* ★リクエストの `group_id` から直接引かないこと★。指示書AL §2-3)。
*
* ★★`customer_id` は必ず1社★★ — ★「顧客が決まらない」ときはデータ層を呼ばず 422★
* (`resolveTargetCustomer()` → `app/api/reports/kpi/route.ts`)。
* ★★静かに1社を選ぶことも、全顧客を合算することもしない★★(D-133 / 落とし穴6)。
*
* ★★複数顧客の行が混ざっていたら例外にする安全弁(D-5 / D-133)は**残してある**★★ —
* `customerSummariesByPeriodKey()`(`lib/db/report-mapping.ts`)。
* ★`where` で絞れているので通常は発火しない★が、★同一顧客でも同じ期間キーの重複は
* 起こりうる(一意制約が無い)★ので検知は必要(期待値表 6-8)。
*/
reportKpis(query: ReportKpiDbQuery): Promise<ReportKpiRow[]>;
/**
* 施設別パフォーマンス。並び替え・`status` フィルタ・`serial` フィルタ・limit はAPIルート側。
*
* ★★施設名の絞り込みを持つのはこのメソッドだけ★★(`d-watch-api.yaml` の
* `/reports/facility-performance` にだけ `facility_name` がある)—
* ★段階2 と同じく「解決済みの施設ID配列」で受ける★。
* ★分類(`group_id` / `customer_id`)との積集合はルート側で取ってから渡す★
* (`intersectFacilityIds()`。指示書AK §6-2)。
*/
facilityPerformance(query: FacilityPerformanceDbQuery): Promise<FacilityPerformanceRow[]>;
/**
* 連続問題施設。2日以上の絞り込みと limit はAPIルート側。
* ★`query.facility_ids` で施設を絞る(`null` = 絞らない / `[]` = 0件。指示書AK 段階4b)★
*/
continuousProblems(query: ReportDbQuery): Promise<ContinuousProblemRow[]>;
/**
* エラー種別内訳。降順ソートはAPIルート側。
* ★`query.facility_ids` で施設を絞る(指示書AK 段階4b §7-3。D-133 申し送り③の解消)★ —
* ★★ただし fixtures 版は `[]` 以外を絞れない★★(理由は `fixtureSource.errorBreakdown()`)。
*/
errorBreakdown(query: ReportDbQuery): Promise<ErrorBreakdownRow[]>;
}
// ---------------------------------------------------------------- エラー一覧の共通部品(指示書AW)
/**
* 並べ替え済みのキーから、そのページの `error_id` を切り出す(fixtures 版・Prisma 版で共有。★規則を2箇所に書かない★)。
* ★範囲外のページは `[]`★(404 にしない。表2 F11)。
*/
function errorReportPageIds(keys: readonly ErrorReportKey[], query: ErrorReportDbQuery): string[] {
const start = (query.page - 1) * query.per_page;
return keys.slice(start, start + query.per_page).map((key) => key.error_id);
}
/**
* ページの `error_id` の順に、行と予定を引き当てて `ErrorReportItem` にする(fixtures 版・Prisma 版で共有)。
*
* ★予定の引き当ては `.toString()` をキーにした Map★(`bigint` は Map のキーにならない。指示書AW 4-9)。
* ★`timetables` は呼び出し側で「同じ id は本表が勝つ」を済ませたもの★(Prisma 版は `findTimetablesByChunks()`。D5)。
* ★`rows` に無い `error_id` は飛ばす(例外にしない)★ — Prisma 版では1段目(キー)と2段目(行)の間に
* 行が消えうる(ステージングのデータ入れ替え。第10回 N-6)。例外にすると1行のために一覧全体が500になる。
* `meta.total` は1段目の件数のまま(1件ずれうる。承知のうえ。指示書AW 4-15)。
*/
function errorReportItemsInPageOrder(
pageIds: readonly string[],
rows: readonly ErrorReportRowSource[],
timetables: readonly ErrorTimetableRef[]
): ErrorReportItem[] {
const rowById = new Map(rows.map((row) => [row.error_id, row]));
const timetableById = new Map(timetables.map((t) => [t.id.toString(), t]));
const items: ErrorReportItem[] = [];
for (const id of pageIds) {
const row = rowById.get(id);
if (!row) continue; // 1段目と2段目の間に消えた行(上の docstring)
const timetable =
row.clean_timetable_id === null ? undefined : timetableById.get(row.clean_timetable_id.toString());
items.push(errorReportItemFromRow(row, timetable));
}
return items;
}
// ---------------------------------------------------------------- fixtures
const fixtureSource: DataSource = {
/**
* ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★(指示書AC 段階2 D-5)。
* ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★(禁止事項6)。
* 絞り込みの中身は `filterFixtureCleanLogs()`(純粋関数)に置いてある。
*/
async cleanLogs(query: CleanLogDbQuery) {
return filterFixtureCleanLogs(cleanLogs, query);
},
/**
* ★fixtures には予定の終了も清掃エリアも無い★(`lib/fixtures/clean-logs.ts`。指示書AV §2 D10)。
* ★終了=開始★(`scheduled_at`。落ち着き時間は開始から数える)、
* ★清掃エリア=ロボット★(`serial_number`。P1 は1エリア=1ロボット。`CLAUDE.md` §8)とみなす。
* ★★fixtures のデータに終了時刻・清掃エリアを足さないこと★★(`docs/fixtures.md` の正を崩さない)。
* ★`cleanLogs()` と同じ `filterFixtureCleanLogs()` を通す★ので、`facility_ids` の
* `null`(絞らない)/ `[]`(0件)の扱いは `cleanLogs()` と同じになる。
*/
async cleanLogRunMeta(query: CleanLogDbQuery) {
return filterFixtureCleanLogs(cleanLogs, query).map((row) => ({
id: row.id,
section_key: `fixture-robot:${row.serial_number}`,
planned_end_at: row.scheduled_at,
}));
},
/**
* ★`deleted_at` の概念が fixtures には無いので全件★(Prisma 版も絞らない。罠7)。
* ★`facilities`(施設マスタの fixtures)から id と名前だけを写す★ —
* ★`cleanLogs` の `facility_name` から作らないこと★(清掃ログが1件も無い施設が
* 解決できなくなり、Prisma 版と結果が食い違う)。
*/
async facilityNameIndex() {
return facilities.map((f) => ({ id: f.id, facility_name: f.facility_name }));
},
/**
* ★Prisma 版と**同じ意味**の行を返す★(指示書AK 段階3 §6-3)。
* ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★(禁止事項6)。
*
* ★`deleted_at` の概念が fixtures には無いので全件★(Prisma 版も絞らない。罠7)。
*
* ★所属は `Facility` 自身が持っている★(指示書AK ★段階5★)—
* 段階1〜4 は `lib/fixtures/customers.ts` の `customerIdByFacilityId` という暫定マップを
* 引いていたが、★段階5 で `Facility` に所属の列が載ったのでマップごと消した★。
* ★所属の割り当てを2箇所に持たないこと★(片方だけ直すと静かにズレる)。
*
* ★fixtures の `group_id` は既に API の形(`null` = 所属なし)★なので変換しない
* (`groupIdFromDb()` を二重に通さないこと。`lib/fixtures/facilities.ts`)。
*/
async facilityGroupIndex() {
return facilities.map(
(f): FacilityGroupRef => ({
id: f.id,
customer_id: f.customer_id,
group_id: f.group_id,
})
);
},
/**
* ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★(指示書AC 段階3)。
* ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★(禁止事項6)。
*/
async areaCompletions(query: AreaCompletionDbQuery) {
return filterFixtureAreaCompletions(areaCompletions, query);
},
/**
* ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★(指示書AC 段階4 / 段階2 D-5)。
* ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★(禁止事項6)。
*/
async prechecks(query: PrecheckDbQuery) {
// ★対応状況の上書き(`PATCH /prechecks/{id}/status`)を反映してから絞る★(指示書AT 段階4-3)
return filterFixturePrechecks(prechecks.map(withFixturePrecheckOverride), query);
},
/**
* ★Prisma 版と同じ判定★(指示書AT §3-3)— id が一致し、かつ `severity === "precheck"` の行だけ。
* ★`launch_failed` の id は null(=404)★。
*/
async precheckItemById(id: string) {
const row = prechecks.find((p) => p.id === id && p.severity === "precheck");
return row ? withFixturePrecheckOverride(row) : null;
},
async updatePrecheckErrorColumns(errorId: string, patch: PrecheckStatusPatch) {
return applyFixturePrecheckPatch(prechecks, errorId, patch);
},
/**
* ★Prisma 版と同じ関数を通す★(指示書AW D13)— キー → `filterSortErrorReportKeys()` → ページ →
* `errorReportItemsInPageOrder()`。★`USE_FIXTURES` で件数・並びが変わらない★(指示書AC 禁止事項6)。
* ★対応状況の上書き(`PATCH /errors/{error_id}/status`)を反映してから絞る★。
*/
async errorReports(query: ErrorReportDbQuery) {
const rows = errorReportRows.map(withFixtureErrorReportOverride);
const keys = filterSortErrorReportKeys(rows.map(errorReportKeyFromRow), query);
const items = errorReportItemsInPageOrder(
errorReportPageIds(keys, query),
rows,
errorReportTimetables
);
return { items, total: keys.length };
},
/** ★Prisma 版と同じ判定★ — `error_id === id`(大小文字を区別する。D-4)の行があれば返す */
async errorReportById(id: string) {
const row = errorReportRows.find((r) => r.error_id === id);
if (!row) return null;
const [item] = errorReportItemsInPageOrder(
[id],
[withFixtureErrorReportOverride(row)],
errorReportTimetables
);
return item ?? null;
},
async updateErrorReportColumns(errorId: string, patch: PrecheckStatusPatch) {
return applyFixtureErrorReportPatch(errorReportRows, errorId, patch);
},
async dsDevices() {
return dsDevices;
},
/**
* ★Prisma 版と**同じ規則**で判定する★ — ★完全一致(`===`)★(D-4)。
* ★★`toUpperCase()` 等で緩めないこと★★ — `USE_FIXTURES` の true/false で
* 404/200 が変わってはいけない(禁止事項6)うえ、緩めると孤児行が残る。
* ★fixtures には論理削除の概念が無い★ので `deleted_at` 相当の絞りは無い。
*/
async dsDeviceExists(serialNumber: string) {
return dsDevices.some((d) => d.serial_number === serialNumber);
},
async dsAlerts() {
return dsAlerts;
},
/**
* ★★`notImplemented` にしないこと★★(指示書AK 落とし穴9)—
* 絞り込みUI(段階7)は `USE_FIXTURES=true` でも開く。ここで落とすと
* ★選択肢が取れず、全画面の絞り込みが出なくなる★。
*/
async groups() {
return groups;
},
/**
* ★fixtures は `group_id` を API の形(`null` = 所属なし)で持つ★(`lib/fixtures/customers.ts`)
*
* ★`id` 昇順にして返す★(指示書AL 段階3 §4-4)— ★Prisma 版の `orderBy` と揃える★。
* ★規則の「正」は `sortCustomers()`★(`lib/masters/query.ts`)なので、ここで
* ★比較関数を書き写さない★。★`customers` を破壊的に並べ替えないこと★(`sortCustomers()`
* は複製してから並べ替える)。
*/
async customers() {
return sortCustomers(customers);
},
async facilities() {
return facilities;
},
async schedules() {
return schedules;
},
async inspections() {
return inspections;
},
async inspectionDetail(id: number) {
return inspectionDetails.find((i) => i.id === id) ?? null;
},
/**
* ★Prisma 版と**同じ判定**を通す★(指示書AI 段階4 / `CLAUDE.md` §9.5)—
* ★★fixtures 用に別の判定を書かないこと★★。判定は `buildRescueRequired()` →
* `judgeRescue()` の1本で、こちらは ★入力の作り方が違うだけ★。
*
* ★★`notImplemented` にしないこと★★ — ダッシュボードは `USE_FIXTURES=true` でも開く。
* ★ここで落とすと、実装した本人が最初に踏む★。
*
* ★入力は fixtures の清掃ログ全件★(★当日で絞らない★。R-1 は3日、R-3 は前回の日、
* R-2 は遡りぶんを必要とする)。
* ★R-3 は1回ごとの完了率の単純平均で判定する★(`meanCleanRatio()`。2026-09-30 D-151・
* 2026-10-02 D-155)ので、
* ★面積が無くても成立する★。旧: 「面積は `areaCompletions` から引く — 仕込みのある施設×日でしか
* R-3 は成立しない」(R-3 が面積加重だった当時の制約。D-151 で解消)。
*
* ★施設名は `facilities`(施設マスタ)から★ — `facilityNameIndex()` と同じ作り方。
* ★清掃ログの `facility_name` から作らないこと★(Prisma 版と食い違う)。
*/
async rescueRequired(query: RescueDbQuery) {
// ★件数の上限は渡さない★(指示書AJ 段階1。該当する施設を全部返す)
// ★★分類の絞り込みは「入力の行」にかける★★(指示書AK 段階4b)—
// `buildRescueRequired()` は ★渡された行に出てくる施設だけ★ を判定するので、
// ★施設名の索引(第2引数)を絞っても母集団は減らない★(あちらは名前の引き当てだけ)。
// ★`null`(絞らない)と `[]`(0件ヒット)の区別は `filterRowsByFacilityIds()` が持つ★。
return buildRescueRequired(
filterRowsByFacilityIds(
rescueInputsFromCleanLogs(cleanLogs, areaCompletions),
query.facility_ids
),
facilities.map((f) => ({ id: f.id, facility_name: f.facility_name })),
query.date
);
},
/**
* 清掃面積(指示書AN 段階3 §6-4)。
*
* ★★`notImplemented` にしないこと★★(指示書AN 落とし穴11)— 清掃面積の画面は
* `USE_FIXTURES=true` でも開く。★ここで落とすと、実装した本人が最初に踏む★。
*
* ★★ロールアップは Prisma 版と同じ `aggregateCleanArea()` を通す★★(`CLAUDE.md` §9.5)—
* ★fixtures 用に別の集計を書かないこと★。★違うのは材料の作り方だけ★
* (こちらは `lib/fixtures/areas.ts` の面積行から作る。`cleanAreaStatsFromFixtureAreas()`)。
*
* ★施設マスタは `facilities`(施設マスタの fixtures)から★ — ★所属は `Facility` 自身が
* 持っている★(指示書AK 段階5)。★`group_id` は既に API の形(`null` = 所属なし)★なので
* `groupIdFromDb()` を二重に通さない。★fixtures に論理削除の概念は無い★ので全件
* (Prisma 版も `deleted_at` で絞らない。★罠7★。理由は `lib/db/clean-area-source.ts`)。
*
* ★番人は常に 0★ — fixtures の面積行は ★1エリア × 1日で1行★(`lib/fixtures/areas.ts` の
* `build()` が施設×日のエリアの顔ぶれを検査している)ので、
* ★1つの清掃に履歴が2行ある状態を作れない★。★★0 を返すのは「異常が無い」という事実であって、
* 未実装の意味ではない★★(ここを `-1` 等にすると番人が誤発火する)。
*/
async cleanArea(query: CleanAreaDbQuery) {
const stats = cleanAreaStatsFromFixtureAreas(areaCompletions, query);
return {
stats,
facilities: facilities.map((f) => ({
id: f.id,
facility_name: f.facility_name,
customer_id: f.customer_id,
customer_name: f.customer_name,
group_id: f.group_id,
group_name: f.group_name,
})),
duplicated_timetable_count: 0,
// ★完了率の材料にした fixtures の行数★(Prisma 版は取り寄せた行数。ログ用。D-156 論点2)
rate_rows: stats.reduce((sum, s) => sum + s.executed_count, 0),
};
},
/**
* ★Prisma 版と**同じ引数**を受け取り、**同じ結果**を返す★(指示書AC 段階5 / 段階2 D-5)。
* ★★`USE_FIXTURES` の true/false で件数が変わってはいけない★★(禁止事項6)。
* 絞り込みの中身は `lib/db/fixture-reports.ts`(純粋関数)に置いてある。
*/
/**
* ★Prisma 版と**同じ意味**で `customer_id` で絞る★(指示書AK ★段階6★ / §2-1)—
* ★`USE_FIXTURES` の true/false で数字が変わってはいけない★(禁止事項6)。
* ★fixtures のKPI行は屋号1 のもの★(`REPORT_KPI_CUSTOMER_ID`)なので、
* ★他の屋号を選ぶと 200・空のKPI★になる(6-9 と同じ形)。
*/
async reportKpis(query: ReportKpiDbQuery) {
return filterFixtureReportKpis(reportKpis, query);
},
/** ★施設IDの絞り込み(`null` と `[]` は別物。罠9)も Prisma 版と同じ意味で効かせる★ */
async facilityPerformance(query: FacilityPerformanceDbQuery) {
return filterFixtureFacilityPerformance(facilityPerformance, query);
},
/** ★施設IDの絞り込み(`null` と `[]` は別物。罠9)も Prisma 版と同じ意味で効かせる★ */
async continuousProblems(query: ReportDbQuery) {
return filterFixtureRowsByFacility(continuousProblems, query);
},
/**
* ★★fixtures だけは施設で絞りきれない(★既知の制約★。指示書AK 段階4b)★★
*
* `errorBreakdown` の fixtures は ★施設をまたいで足し上げたあとの集計値★
* (`lib/fixtures/reports.ts` の `errorCounts`。`type` / `label` / `count` / `rate` しか無く、
* ★どの施設のエラーかという情報を持っていない★)ので、
* ★「屋号1の施設ぶんだけ」に割り戻すことができません★。
*
* | `facility_ids` | ここでの扱い |
* |---|---|
* | `null`(絞らない) | ★期間の行をそのまま★ |
* | ★`[]`(0件ヒット)★ | ★★空配列★★(★施設が1つも当たらない=エラーも0件★。ここは正確) |
* | ★1件以上★ | ★★全社ぶんを返す(=絞り込みが効かない)★★ |
*
* ★★`USE_FIXTURES=true` のときだけ件数が多く出ます★★(禁止事項6 の例外)。
* ★直すには fixtures のエラー内訳を「施設ごとの件数」から組み立て直す必要があり、
* `rate`(全エラー件数に対する割合)の分母も作り直しになる★ため、
* ★実データの分布が分からないうちは捏造しない★(`CLAUDE.md` §7「推測で実装しない」)。
* ★Prisma 版(`facility_error_summaries` / `robot_error_reports`)は施設で絞れています★。
*/
async errorBreakdown(query: ReportDbQuery) {
// ★★`!== null` で分岐すること★★(罠1)— `?.length` だと `null` も `[]` も同じ枝になる
if (query.facility_ids !== null && query.facility_ids.length === 0) return [];
return filterFixtureReportRows(errorBreakdown, query);
},
};
// ---------------------------------------------------------------- prisma
/**
* 実DB実装。
*
* TeamWorksがDBを構築し `prisma db pull` でスキーマを取り込んだあとに実装します。
* それまでは呼ばれたら分かるように明示的にエラーを投げます
* (黙って空配列を返すと「データがない」のか「未実装」なのか切り分けられないため)。
*/
function notImplemented(name: string): never {
throw new Error(
`[db] ${name} のPrisma実装はまだありません。USE_FIXTURES=true で開発してください。`
);
}
/**
* ★`prismaSource.cleanLogs()` と `prismaSource.cleanLogRunMeta()` が共有する `where`★(指示書AV 段階3-3 / 4-8)。
*
* ★★2本で別々に書かないこと★★ — 片方だけ直すと、`pickLatestRunPerSection()` の id の突き合わせが
* 静かにずれる(行に対応するメタが無ければ例外、メタが余れば無視されるだけ)。
* ★中身は `cleanLogs()` にあった `where` をそのまま移したもの★(1文字も変えていない。コメントも一緒に移した)。
*/
function mainTimetableWhere(
query: CleanLogDbQuery
): NonNullable<TimetableFindManyArgs["where"]> {
// ★★`parseDate()` の返す「真の瞬間」をそのまま渡さないこと★★ —
// DB の DATETIME は日本時間の壁時計値なので9時間ずれる(指示書AC C-6)。
// ★変換は `dbDateRange()` の1箇所に閉じ込めてある(`toDbJst()` を通す)★。
// ★★ローカルDB(深夜0〜3時のデータしか無い)では付け忘れを検出できない★★ので、
// `lib/db/__tests__/date-range.test.ts` の合成データで固定している。
const range = dbDateRange(query.date_from, query.date_to);
return {
// ★本清掃だけを引く(メモリ側の filter をやめて SQL に降ろした)★
precheck: CLEAN_TIMETABLE_PRECHECK.main,
// ★★単一ID の取得経路(指示書AC 段階3)★★ — `/clean-logs/{id}/advice` と
// `/clean-logs/{id}/areas` はここを通って1件だけ引く。
// ★`clean_timetables.id` は `BigInt` 列なので `BigInt()` に包む★ —
// ★Prisma の型は `number` も受け付ける(実測。`tsc` は通る)★が、
// ★★このファイルの他の箇所(`key()` / `parent_id` / `clean_timetable_id`)が
// すべて `bigint` を扱っている★★ので、DB に渡す形をそこに揃えておく。
// ★`query.id` は `parsePathId()` が安全整数に限っている★ので変換で桁が落ちない。
// ★★`!== null` で分岐すること★★(罠1と同じ理由。`query.id ? ... : {}` と
// 書くと `0` が「指定なし=全件」に化ける)。
...(query.id !== null ? { id: BigInt(query.id) } : {}),
// ★`gte` も `lt` も無いときは `start_at` のキーごと付けない★
...(range.gte || range.lt ? { start_at: range } : {}),
// ★★必ず `!== null` で分岐すること★★(★罠1★)—
// ★`in: undefined` は**条件が消えて全件を返す**★(生成SQL が `WHERE 1=1`)。
// ★`in: []` なら `WHERE 1=0` で0件★=「施設名で照合したが1件も当たらなかった」。
// ★★`query.facility_ids?.length ? ... : {}` と書かないこと★★ —
// ★「0件ヒット」が「全件表示」に化け、例外もテスト失敗も出ません★。
...(query.facility_ids !== null
? { facility_id: { in: query.facility_ids } }
: {}),
};
}
const prismaSource: DataSource = {
/**
* 清掃詳細ログ(F-02)+ ダッシュボード(F-01)+ アラート(F-03)が使う一覧。
*
* ★★`include` が使えないので手動 join する★★(指示書R 落とし穴1 / D-092 項3-3)
* `clean_statuses.clean_timetable_id`(`prisma/schema.prisma:445`)/
* `clean_task_reports.clean_timetable_id`(`:524`。★nullable★。指示書AQ 決定2/3 で
* `clean_status_histories` から差し替えた)/
* `robot_error_reports.clean_timetable_id`(`:322`)には ★FK も KEY も無い★。
* Prisma のリレーションが張られていないため `include` で辿れない。そこで
* 別クエリで取得し、`clean_timetable_id` をキーにメモリ上で突き合わせている。
* ★D-094 で TeamWorks に FK 追加を再依頼中★。FK が付いたらこの手動 join は
* そのまま `include` に書き換えられる(そのときは下の Map 3本が消える)。
*
* ★1回の清掃は `clean_timetables` の3行で表される★(D-092 項1):
* `precheck = 0` … 本清掃(`parent_id = 0`)… これが `CleanLogItem` 1件の骨格
* `precheck = 1` … 1回目の事前チェック(60分前)… `parent_id` = 本清掃の id
* `precheck = 2` … 2回目の事前チェック(30分前)… 同上
*
* ★`deleted_at` はこの4テーブルのどれにも無い★ので論理削除で絞らない。
* `clean_timetables.inactive` でも絞らない(第1歩・第2歩と同じ方針。指示書R 禁止事項4/5)。
* ★★`include: { facility: true }` にも `deleted_at` の条件が無い★★ —
* ★論理削除された施設のログも返している★(それが現状の挙動)。
* ★★だから施設名の解決も `deleted_at` で絞らない★★(罠7。
* `lib/db/facility-name-source.ts`)。
*
* ★★性能(指示書AC 段階2 で解決)★★: 以前は `DataSource` が引数を取らない契約だったため
* ★全期間・全件★を読んでいた(D-092 項3-3。保持2年で `clean_timetables` は約26万行)。
* ★★いまは日付範囲・施設ID・単一ID・本清掃(`precheck = 0`)・並び順を SQL に降ろしている★★
* (★単一ID(`query.id`)は段階3。`/clean-logs/{id}/advice` と `/{id}/areas` が使う★)。
* ★索引は最初から張られている★(`@@index([start_at])` / `@@index([facility_id])` /
* `@@index([parent_id])`。`prisma/schema.prisma`)ので、TeamWorks への依頼は要らない。
*
* ★★降ろせないもの★★(指示書AC C-1。★取得後にメモリで絞る★):
* - ★判定(◎○△×)★ … 完了率(`runCompletionRate()`)から `judgeGrade()` で計算しており DB に列が無い
* - ★連続日数 / エスカレーション★ … 日をまたいで数える(D-026)
* - ★機種 / シリアルの部分一致★ … 索引が効かず利得が薄いので据え置き
* - ★施設名の部分一致★ … ★記号を無視する照合★なので LIKE では書けない。
* ★API ルート側で `resolveFacilityIdsByName()` が施設IDに解決してから渡す★
* - ★`take` / `skip`★ … 判定フィルタが SQL で絞れないため、降ろすと `meta.total` が
* 食い違う(指示書AC D-3。★P1ではやらないという決定★)
*
* ★★連続日数だけは「表示期間そのもの」で絞らない★★(★★指示書AC C-2 の地雷★★)—
* 期間で絞ると前日のデータが消えて ★連続が途切れ、L3 が L1 に化ける
* (例外もテスト失敗も出ない)★。★★`loadConsecutiveInputs()` は表示期間の手前を
* 「保存された表示上限 +1日」ぶん遡って読む★★(段階6)。★遡り幅の計算を
* ここに書き下さないこと★ — 3経路で食い違う(D-109 の再発防止)。
*/
async cleanLogs(query: CleanLogDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★★予定は `clean_timetables` と `clean_timetable_histories` の両方から読む★★
// (指示書AU 段階3b。第11回 P-7。★同じ `args` を2表に渡す・同じ id は本表を採る★のは `findTimetables()`)
const mainTimetables = await findTimetables(prisma, {
where: mainTimetableWhere(query),
// ★リレーションがある結合先は include してよい(落とし穴1 は FK が無い3テーブルの話)★
include: {
facility: true,
section: {
include: {
robots: {
where: { deleted_at: null },
include: { device: true },
},
},
},
},
// ★並び順も SQL に降ろす(`start_at` に索引がある)★。
// ★下の `.sort()` は残す★ — 規則の正はデータソースの戻り値であって
// `orderBy` はその写し(C-5 と同じ理由。`sort` は安定なので二度手間にならない)。
// ★★同着の第2キーは `id` の**昇順**★★ — ★指示書AC D-4 の例は `id: "desc"` だが、
// ★★下の `.sort()` が以前から `a.id - b.id`(昇順)で並べている★★ので、
// そちらに合わせる(★段階2 はレスポンスを1バイトも変えない★。指示書AC 禁止事項14)。
// ★★2表を合わせるので、DB の順序はもう写しにもならない★★(指示書AU 段階3b-2 の 3)—
// ★順序の正は下の `.sort()` だけ★。
orderBy: [{ start_at: "desc" }, { id: "asc" }],
});
// BigInt はキーとして === で比較できないので文字列化して引く
const key = (id: bigint) => id.toString();
// ★引いた本清掃の id で、関連する行を引き直す★(★全件読みに戻さないこと★)。
// ★`in` の要素数には上限がある★(`DB_IN_CLAUSE_MAX_ITEMS`。指示書AC §E-5)—
// ★★超える場合は分割する(`facility_id` + 日付での絞り直しは採らない)★★。
// 理由は `DB_IN_CLAUSE_MAX_ITEMS` の docstring(別経路で行が選ばれると
// ずれたときに行が静かに落ちる)。
// ★塊が0個(本清掃が0件)ならクエリを1本も撃たない★。
const mainIds = mainTimetables.map((row) => row.id);
const mainIdChunks = chunkForInClause(mainIds);
// ★事前チェック行(`precheck !== 0`)は `parent_id` で別クエリに分ける★(指示書AC D-4)—
// ★★1回のクエリで `precheck` 混在のまま引いて、あとでメモリで分けるやり方に
// 戻さないこと★★(それだと本清掃の絞り込みが SQL で効かない)。
// ★`parent_id` には索引がある★(`clean_timetables_parent_id_index`)。
// ★読む列は3つだけ★ — 下で使うのは `parent_id` / `precheck` / `id` のみ
// (事前チェック行の `facility` や `section` は使わないので `include` しない)。
// ★★2表から読み、全塊を合わせてから1回だけ id で重複を落とす★★(指示書AU 段階3b-3)—
// 2表で `parent_id` が違う同じ id は別の塊に入りうるので、塊の中の重複落としだけでは足りない。
const precheckRows = await findTimetablesByChunks(prisma, mainIdChunks, (ids) => ({
where: { parent_id: { in: ids } },
select: { id: true, parent_id: true, precheck: true },
}));
// ★FK が無いテーブルは別クエリ+メモリ上の手動 join★
// ★清掃結果(`clean_task_reports`)は**本清掃の id**、エラー報告は**事前チェック行の id**
// でしか引かない★(下の組み立てがそれぞれその id でしか Map を引かないため。★絞りすぎていない★)
// ★★清掃結果の取得元は `clean_task_reports`★★(指示書AQ 決定2/3。2026-09-30。
// `clean_status_histories` は使わない)。
// ★`clean_timetable_id` が NULL のレポートは `in` に一致しない★ので、ここで引けない
// (=その本清掃は「レポートの行が無い」=出発失敗として出る。指示書AQ 罠1 / 決定5)
const precheckIds = precheckRows.map((row) => row.id);
const [reportChunks, errorReportChunks] = await Promise.all([
Promise.all(
mainIdChunks.map((ids) =>
prisma.cleanTaskReport.findMany({
where: { clean_timetable_id: { in: ids } },
})
)
),
Promise.all(
chunkForInClause(precheckIds).map((ids) =>
prisma.robotErrorReport.findMany({
where: { clean_timetable_id: { in: ids } },
// ★`robot_id` / `robot` を読まない(NULL の行で落ちないため。指示書AU 段階0-4)★
select: ROBOT_ERROR_STATUS_SELECT,
})
)
),
]);
const taskReports = reportChunks.flat();
const errorReports = errorReportChunks.flat();
// ★レポートを1件に絞る規則は `latestReportByTimetableId()` の1箇所★(D-107 / 指示書AQ §1-4)—
// `prechecks()` と同じ関数を使う。★順序キー(`ended_at ?? started_at`)を決めるのもあちら★。
// ★ここで `orderBy` を足したり別の順序キーを書かないこと★(`/detail-log` と `/alerts` で
// 選ばれる行が食い違う。D-103 が禁じた状態)
const reportByTimetableId = latestReportByTimetableId(taskReports);
const errorsByTimetableId = new Map<string, RobotErrorLike[]>();
for (const report of errorReports) {
// ★nullable(清掃タイムテーブルに紐づかないエラーがありうる。`prisma/schema.prisma:322`)★
if (report.clean_timetable_id === null) continue;
const k = key(report.clean_timetable_id);
const list = errorsByTimetableId.get(k);
const entry: RobotErrorLike = {
error_type: report.error_type,
errored_at: report.errored_at,
};
if (list) list.push(entry);
else errorsByTimetableId.set(k, [entry]);
}
// parent_id(本清掃の id)→ precheck コード → 事前チェック行
// ★`precheckRows` は `parent_id: { in: 本清掃の id }` で引いた行★(=本清掃行は入らない)。
// ★それでも `precheck === main` を弾く行は残す★ — `parent_id` が 0 の本清掃行が
// 混ざる余地(`parent_id = 0` を指す行)を潰しておくため。★条件を緩めないこと★
const precheckByParent = new Map<string, Map<number, (typeof precheckRows)[number]>>();
for (const row of precheckRows) {
if (row.precheck === CLEAN_TIMETABLE_PRECHECK.main) continue;
const k = key(row.parent_id);
const byCode = precheckByParent.get(k) ?? new Map();
byCode.set(row.precheck, row);
precheckByParent.set(k, byCode);
}
// ★予定時刻がまだ来ていない本清掃は**行ごと結果に含めない**★(`prechecks()` と同じ判定)
// 清掃詳細ログ(F-02)は「実施済みの記録」であり、未実施の未来予定を
// 「×・未実施」として出すと `computeConsecutiveProblemDays()` の連続日数まで水増しされる。
// ★★`isFutureSchedule()` は D-107(設計3-C)の下では**必須**。外さないこと★★ —
// 未来の予定にはまだレポートの行が無く、`notExecutedFromReport()` は
// 「レポートの行が無い」を出発失敗と判定するので、★外すと未来の清掃が全部「出発失敗」になる★。
// ★★逆に「レポートがあること」をこのフィルタに足さないこと★★(D-107 設計3-C)—
// ★レポートの行が無い過去の本清掃も**返す**(出発失敗として画面に出す)★。
// 足すと、データ収集処理の障害で結果が書かれなかった清掃が
// ★件数がそっと減るだけで画面から消える★(fail-silent)。
// ★`now` は1回だけ作って全行で使い回す★(行ごとに作ると境界の行の扱いがぶれる)
// ★★`precheck === main` の絞り込みは SQL に降ろした★★(指示書AC 段階2 D-4)ので、
// ここに残っているのは ★未来の予定を外す条件だけ★。
// ★★SQL 側の `where` から `precheck` を外さないこと★★ — 外すと事前チェック行が
// 一覧の行として並ぶ(`section.robots[0]` が同じなので例外にもならない)。
const now = new Date();
const mainRows = mainTimetables.filter(
(row) => !isFutureSchedule(fromDbJst(row.start_at), now)
);
// 先に骨格(judge に渡す値まで)を作る。連続日数は全行が揃わないと数えられないため2段構え
const drafts = mainRows.map((row) => {
// ★P1は1清掃エリア=1ロボットの前提(CLAUDE.md §8)★。0台なら静かに空文字を返さず例外
// (`schedules()` の :301-306 と同じ作法)
const robot = row.section.robots[0];
if (!robot) {
throw new Error(
`[db] cleanLogs: clean_timetables(id=${row.id}) の section(id=${row.section_id}) にロボットが見つかりません。P1は1清掃エリア=1ロボットの前提です。`
);
}
const precheckRows = precheckByParent.get(key(row.id));
// ★status_1h / status_30m の列の割り当ては「分数の一致」ではなく「順番」★(D-178。ユーザー判断 2026-10-16)—
// 事前チェックの分数は設定で変わりうる(例: 60/10)ので、早い方を1回目・遅い方を2回目にする。
// `precheckRows`(`precheck` の生値 → 行)を「`precheck` の生値 → エラー配列」に
// 詰め替えてから、規則を1箇所に持つ `statusColumnsFromPrecheckRows()` に渡す。
// ★予定行があればエラーが無くても空配列でキーを入れる★ — ★キーが無い回は null(予定が無い)になる★
// (ユーザー指示 2026-10-07。D-171)。★`?? []` を外したりエラーの有無で入れ分けたりしないこと★
// (入れ分けると、異常なしの回が消えて列がずれる)
const errorsByPrecheck = new Map<number, RobotErrorLike[]>();
if (precheckRows) {
for (const [precheck, precheckRow] of precheckRows) {
errorsByPrecheck.set(
precheck,
errorsByTimetableId.get(key(precheckRow.id)) ?? []
);
}
}
const precheckColumns = statusColumnsFromPrecheckRows(errorsByPrecheck);
// ★列は2つしか無いので、3つ以上の事前チェックは開始に近い2つだけ残る★(1清掃2回で固定なので通常は起きない)。
// ★黙って捨てない★ — 捨てた分数をこの行の id と一緒に残す(原因の断定は書かない)。
// ★データ層はルートを知らないので `route` / `method` は載せない★(不明な情報は書かない)
if (precheckColumns.dropped.length > 0) {
logEvent("warn", {
event: "data.anomaly",
metric: "precheck_rounds_per_main_run",
expected: PRECHECK_COLUMN_COUNT,
actual: precheckColumns.dropped.length + PRECHECK_COLUMN_COUNT,
clean_timetable_id: String(row.id),
dropped_precheck_minutes: precheckColumns.dropped,
});
}
// ★「出発したか」は DB の値で決める。「予定+猶予30分」を当方で計算しない★
// (D-091 項1 / 指示書R 禁止事項3。LAUNCH_GRACE_MINUTES はここから読まない)
// ★★出発失敗(未実施)= 本清掃に対応する `clean_task_reports` の行が無い★★
// (指示書AQ §1-1。旧取得元の `clean_error === 1` の分岐は、列が無いので消えた)。
// ★判定そのものは `notExecutedFromReport()`(clean-log-mapping.ts)に切り出してある★ —
// `prechecks()` の `launch_failed` が同じ判定を共有するため(D-103 項1)。
// ★条件を**ここに書かないこと**★(書くと規則が2箇所に散る)。
// ★`status` を未実施の判定に使わないこと★(値の意味は第10回 N-2 で受領済みだが、
// 判定には使わない。D-155 項2。ユーザー決定 2026-10-02。指示書AQ §1-2 / D-077)
const report = reportByTimetableId.get(key(row.id));
return {
id: Number(row.id),
facility_id: Number(row.facility_id),
facility_name: row.facility.facility_name,
model_name: formatModelName(robot.device.maker_id, robot.device.model_name),
serial_number: robot.serial_number,
// ★DBのDATETIMEには日本時間の壁時計値が入っている(TeamWorks第8回A2。指示書Y)。
// Prismaから出たDateは必ずjstIsoFromDb()を通す(toJstIso()に直接渡さない)★
scheduled_at: jstIsoFromDb(row.start_at),
status_1h: precheckColumns.status[0],
status_1h_minutes: precheckColumns.minutes[0],
status_30m: precheckColumns.status[1],
status_30m_minutes: precheckColumns.minutes[1],
departed: !notExecutedFromReport(report),
// ★`clean_task_reports.status` の生値をそのまま運ぶ(変換しない。指示書AQ §1-2 / D-077)★。
// ★レポートの行が無ければ null★。★旧 `clean_error` から改名した(指示書AQ §5-2)★ —
// ★中身が別の列になったので、旧コード表(0:正常 / 1:未実施 …)で読まれないようにするため★。
// ★★`??` を `||` にしないこと★★ — `status` の既定値は `0`(`prisma/schema.prisma`)で、
// `||` だと ★0 が null に化ける★(`tsc` も `lint` も捕まえない)
task_status: report?.status ?? null,
// ★結果待ち(ユーザー判断 2026-10-07。D-173)★ — 判定は `isResultPending()` の1本。
// ★`departed` / `task_status` / `completion_rate` / `grade` は結果待ちでも変えない★(別の旗で表す)。
// ★`end_at` は `start_at` と同じ JST の壁時計値なので `fromDbJst()` を通す★(9時間を足さない・引かない)。
// ★`now` は上で作った同じインスタンス★(`mainRows` の未来の予定の除外と基準時刻を揃える)
result_pending: isResultPending({
startAt: fromDbJst(row.start_at),
endAt: fromDbJst(row.end_at),
now,
hasReport: !notExecutedFromReport(report),
}),
// ★丸めない生値(CLAUDE.md §4.3)。未実施(レポートなし)は null★
// ★★`report ? report.clean_ratio : null` の形に戻さないこと★★ — 判定を
// `completionRateFromReport()` の外に書くと、`departed` と規則が2箇所に散る
// (★`departed === false` ⟺ `completion_rate === null`★ の不変条件。P-5=B で `task_area = 0` が
// 0 になり両向きに成り立つ。D-107 落とし穴1 / D-155 / D-156 / D-158)
completion_rate: completionRateFromReport(report),
};
});
// ★連続日数は施設ごとの全期間走査(D-026 / D-090 項15 / D-109)★
// ★★入力は `loadConsecutiveInputs()`(`lib/db/consecutive-source.ts`)の1本に集約してある★★
// (`facilityPerformance()` / `continuousProblems()` と同じ1本。D-109 の再発防止)。
// ★★`drafts` から作らないこと★★ — ★段階2 で `drafts` は期間・施設で絞られた★ので、
// そこから作ると期間の外にある前日のデータが消えて連続が途切れる
// (指示書AC C-2。★L3 が L1 に化ける fail-silent★)。
// ★★段階6: `loadConsecutiveInputs()` には「★表示したい期間そのもの★」を渡すこと★★ —
// ★遡りぶん(保存された表示上限 +1日)は同関数が足す★(★ここで足さないこと★。
// 3経路が別々の遡り幅を持つと `/detail-log` と `/reports` で数字が食い違う。D-109)。
// ★★`query.date_from` が `null`(単一ID取得など)なら下限なし=全期間のまま★★。
// ★`now` は上で作った同じインスタンスを渡す★(`mainRows` の絞り込みと基準時刻を揃える)
const consecutiveRange: ConsecutiveDbRange = {
date_from: query.date_from,
date_to: query.date_to,
};
const consecutiveById = computeConsecutiveProblemDays(
await loadConsecutiveInputs(prisma, now, consecutiveRange)
);
return drafts
.map((draft): CleanLogItem => {
const consecutive = consecutiveById.get(draft.id) ?? 0;
return {
...draft,
// ★judge は prismaSource の中で呼ぶ(D-090 項16)。fixtures 版も同じ関数を通している★
grade: judgeGrade(draft.completion_rate),
consecutive_problem_days: consecutive,
escalation_level: judgeEscalationLevel(consecutive),
};
})
// ★fixtures と同じ「新しい順」で返す(lib/fixtures/clean-logs.ts:227-233)★
// APIルート側は並べ替えないので、順序はデータソースの責任
.sort((a, b) => {
if (a.scheduled_at !== b.scheduled_at) {
return a.scheduled_at < b.scheduled_at ? 1 : -1;
}
return a.id - b.id;
});
},
/**
* ★清掃ログの id → { 清掃エリア, 予定の終了 }★(ダッシュボードの直近モード専用。D-159 / 指示書AV 段階3-3)。
*
* ★★`where` は `cleanLogs()` と同じ `mainTimetableWhere()`★★(4-8)— 別に書くと、片方だけ直したときに
* `pickLatestRunPerSection()` の突き合わせが静かにずれる。
* ★`findTimetables()` で2表から読む★(同じ id は本表が勝つ。指示書AU 段階3b)。
* ★`prisma.cleanTimetable.findMany` を直接呼ばないこと★ — 履歴側だけにある予定のメタが欠け、E1 の例外になる。
* ★`end_at` は `start_at` と同じ DATETIME の JST 壁時計値★なので `jstIsoFromDb()` を通す
* (★9時間を足さない・引かない★。`CLAUDE.md` §3 / 指示書AV 4-3)。
* ★`cleanLogs()` が外す未来の予定(`isFutureSchedule()`)もここには載る★ — 呼び出し側は無視する(表2 E6)。
*/
async cleanLogRunMeta(query: CleanLogDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
const rows = await findTimetables(prisma, {
where: mainTimetableWhere(query),
// ★`id: true` は必須★(2表の重複を id で除くため。`TimetableFindManyArgs` が型で強制している)
select: { id: true, section_id: true, end_at: true },
});
return rows.map((row) => ({
id: Number(row.id),
section_key: `section:${row.section_id.toString()}`,
planned_end_at: jstIsoFromDb(row.end_at),
}));
},
/**
* ★施設名 → 施設ID の解決★用の最小限の一覧(指示書AC 段階2 D-6)。
*
* ★★`facilities()` を流用しないこと★★ — あちらは `where: { deleted_at: null }` を付けている。
* ★論理削除された施設のログは `cleanLogs()` にいま出ている★ので、ここで絞ると
* ★その施設名で検索したときにログが静かに消える★(★罠7★)。
* ★読み取り(`where` を付けない理由を含む)は `loadFacilityNameIndex()` に閉じ込めてある★。
*/
async facilityNameIndex() {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
return loadFacilityNameIndex(prisma);
},
/**
* ★分類(ホールディングス/屋号)→ 施設ID の解決★用の最小限の一覧(指示書AK 段階3 §6-3)。
*
* ★★`facilities()` を流用しないこと★★ — あちらは `where: { deleted_at: null }` を付けている。
* ★論理削除された施設のログは `cleanLogs()` にいま出ている★ので、ここで絞ると
* ★屋号やホールディングスで絞った瞬間にそのログが静かに消える★(★罠7★)。
* ★読み取り(`where` を付けない理由・`group_id` の 0 → null 変換を含む)は
* `loadFacilityGroupIndex()` に閉じ込めてある★。
*/
async facilityGroupIndex() {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
return loadFacilityGroupIndex(prisma);
},
/**
* エリア別完了率(F-02 のエリアモーダル。GET /clean-logs/{id}/areas)。
*
* ★出所★: TeamWorks 第8回 A9-2(2026-09-11 受領)—
* 「エリア毎の清掃完了率は取得可能。正確に言えば清掃ロボット毎の清掃率だが、
* 清掃ロボットはエリアに紐づいている」。つまり ★1エリア(=1ロボット)につき1つの完了率★
* であり、★1回の清掃の中を売り場・通路・レジ前に区切った区域別の内訳ではない★
* (確認事項#10 の本来の問いは未解決のまま。第9回で聞き直し中)。
*
* ★★データ元は `clean_task_reports`★★(指示書AQ 決定2/3。2026-09-30。
* ★旧: `clean_status_histories`。指示書Z 決定6 の「`clean_task_reports` は読まない」は
* ★取得元の一本化で失効した★)。
* ★`cleanLogs()` が読んでいるのと**同じテーブル・同じ規則**で完了率を作る★という理由は
* 変わっていない — 別テーブルから作ると、★同じ画面(一覧の行 → そこから開くモーダル)に
* 定義の違う2つの完了率が並ぶ★。★両方を同時に `clean_task_reports` へ切り替えた★。
*
* ★★行と全体の完了率★★:
* - ★各行の `completion_rate` は `completionRateFromReport()`=1回ごとの完了率(`runCompletionRate()`)★。
* ★`cleanLogs()` と同じ関数を通す★(別の書き方をすると `cleanLogs()` の完了率と食い違う)。
* - ★全体率は行の `completion_rate` の単純平均(清掃1回=1票。未実施の行は除く)★ —
* ★計算しているのは APIルート側の `buildAreaCompletions()`(`lib/clean-logs/areas.ts`)★。
* ここでは行を返すだけで、全体率は出さない。
* - ★この層が運んでいる面積(`clean_area` / `task_area`)は表示用★。全体率の分子・分母ではない
* (行の完了率は面積から作るが、それは `completionRateFromReport()` の中で1回ずつ行う)
* (`CLAUDE.md` §4.2。★以前は全体率だけ面積ベースの「意図した非対称」だった(指示書Z 決定7)が、
* 2026-09-30 に単純平均へ揃えた。D-151 / 指示書AQ §5-5★)。
*
* ★`cleanLogs()` と同じ作法で書いてある★(新しい読み方を発明しない):
* `section` はリレーションがあるので `include`、`clean_task_reports` は
* ★FK も KEY も無いので別クエリ+手動 join★、レポートの1件への絞り込みは
* ★`latestReportByTimetableId()`(同じ関数)★、未来の予定は ★`isFutureSchedule()`★ で除外。
*
* ★★`cleanLogs()` のような「ロボット0台なら例外」の処理は入れない★★ —
* この API は ★ロボットではなくエリアを列挙する★ので、`section_name` があれば足りる
* (`AreaCompletionRow` は `serial_number` を持たない)。★ロボット0台の施設を落とさないこと★。
*
* ★★性能(指示書AC 段階3 で解決)★★: 以前は引数を取らず ★全期間・全件★ を読んでいた
* (実測 1,582行。しかもエリアモーダルは ★1施設の1日ぶんしか使わない★)。
* ★★いまは施設ID・日付1日ぶん・本清掃(`precheck = 0`)を SQL に降ろしている★★。
* ★索引は最初から張られている★(`@@index([facility_id])` / `@@index([start_at])`)。
*
* ★★連続日数は関係しない★★ — この API は連続日数を返さないので、
* `cleanLogs()` のような「全期間を読み続ける1本」(C-2 / C-3)は要らない。
*/
async areaCompletions(query: AreaCompletionDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★★`query.date` の生の文字列から `Date` を作らないこと★★ — DB の DATETIME は
// 日本時間の壁時計値なので9時間ずれる(指示書AC C-6)。★変換は `dbDateRange()` の1箇所★。
// ★1日ぶんなので `from` と `to` に同じ日を渡す★(`to` はその日を含む=翌日 00:00 未満)。
const range = dbDateRange(query.date, query.date);
// ★予定は2表から読む(指示書AU 段階3b。`findTimetables()`)★
const timetables = await findTimetables(prisma, {
where: {
// ★事前チェック行を混ぜない(本清掃だけ)★ — ★メモリ側の filter をやめて SQL に降ろした★
// ★★SQL 側の `where` から `precheck` を外さないこと★★(`cleanLogs()` と同じ。
// 外すと事前チェック行がエリアとして並ぶ。`section` が同じなので例外にもならない)
precheck: CLEAN_TIMETABLE_PRECHECK.main,
// ★★`!== null` で分岐すること★★(罠1と同じ理由)。
// ★`clean_timetables.facility_id` は `BigInt` 列なので包む★
// (理由は `cleanLogs()` の `query.id` と同じ)
...(query.facility_id !== null
? { facility_id: BigInt(query.facility_id) }
: {}),
...(range.gte || range.lt ? { start_at: range } : {}),
},
// ★リレーションがある結合先は include してよい(FK が無いのはレポート側)★
include: { section: true },
});
// ★FK が無いテーブルは別クエリ+メモリ上の手動 join★(`cleanLogs()` と同じ)—
// ★★全件読みに戻さないこと★★。★引いた本清掃の id で引き直す★(指示書AC D-4 / §E-5。
// `in` の要素数の上限と分割は `chunkForInClause()` に閉じ込めてある)。
// ★塊が0個(本清掃が0件)ならクエリを1本も撃たない★。
// ★取得元は `clean_task_reports`★(指示書AQ 決定2/3。`cleanLogs()` と同時に切り替えた)
const reportChunks = await Promise.all(
chunkForInClause(timetables.map((row) => row.id)).map((ids) =>
prisma.cleanTaskReport.findMany({
where: { clean_timetable_id: { in: ids } },
})
)
);
// ★レポートを1件に絞る規則は `latestReportByTimetableId()` の1箇所★(D-107 / 指示書AQ §1-4)
const reportByTimetableId = latestReportByTimetableId(reportChunks.flat());
// ★`now` は1回だけ作って全行で使い回す★(行ごとに作ると境界の行の扱いがぶれる)
const now = new Date();
return timetables
.filter(
(row) =>
// ★★外すと未来の予定が「未実施」として並ぶ★★(D-107 / D-109。`cleanLogs()` と同じ理由)
// ★★`precheck === main` の絞り込みは SQL に降ろした★★ので、
// ここに残っているのは ★未来の予定を外す条件だけ★。
!isFutureSchedule(fromDbJst(row.start_at), now)
)
.map((row): AreaCompletionRow => {
const report = reportByTimetableId.get(row.id.toString());
return {
clean_log_id: Number(row.id),
facility_id: Number(row.facility_id),
// ★DBのDATETIMEは日本時間の壁時計値(指示書Y / D-115)★。
// ★★`row.start_at` の生の `Date` で日付を作らないこと★★(9時間ずれる)
date: jstIsoFromDb(row.start_at).slice(0, 10),
section_id: Number(row.section_id),
// ★`sections.section_name` の生値。当方で整形しない★
section_name: row.section.section_name,
// ★未実施は `null`(`0`=「0%で実施した」と混ぜない)★(D-107 落とし穴1)
completion_rate: completionRateFromReport(report),
// ★DBの値をそのまま足す(未実施だからといって当方で 0 に潰さない)★
// ★`aggregateCleanStatsByFacility()` と**同じ扱い**★(指示書Z 決定13)。
// ★★`??` を `||` にしないこと★★ — 面積は `0` が正当な値
// ★面積は残す(指示書AQ §1-3)。★全体率の分子・分母には使わない★
// (行の完了率は上の `completionRateFromReport()` が面積から1回ずつ出す。D-155)
clean_area: report?.clean_area ?? 0,
task_area: report?.task_area ?? 0,
};
});
},
/**
* アラート(事前チェック)一覧(F-03)。
*
* ★★2種類の行を1つの配列にまとめて返す★★:
*
* 1. `severity: "precheck"` … ★事前チェック行に紐づく `robot_error_reports` の行★
* 2. `severity: "launch_failed"` … ★本清掃が実施されなかったこと★
* (★対応する `clean_task_reports` の行が無い★。D-103 / D-107 / 指示書AQ §1-1。
* ★旧取得元 `clean_status_histories` の `clean_error === 1` の分岐は、列が無いので消えた★)
*
* ★2番目がエラー行由来でない点に注意★ — `launch_failed` は
* `cleanLogs()` の `departed === false` と ★同じ出来事★ なので、
* ★同じ判定(`notExecutedFromReport()`)を共有する★(D-103。指示書S の B案)。
* ★「本清掃行を指すエラー行」を `launch_failed` に読み替えてはいけない★ —
* それをすると「エラー行があること」が出発失敗の条件になり、`departed` と別定義になる。
*
* ★★`include` が使えないので手動 join する★★(`cleanLogs()` と同じ。指示書S 落とし穴1)
* `robot_error_reports.clean_timetable_id`(`prisma/schema.prisma:322`)には
* ★FK も KEY も無く(しかも `BigInt?`)★、Prisma のリレーションが張られていないため
* `include` で `clean_timetables` を辿れない。そこで別クエリで取得し、
* `clean_timetables.id` をキーにメモリ上で突き合わせている。
* ★D-094 で TeamWorks に FK 追加を再依頼中★。FK が付いたら `include` に書き換えられる。
*
* ★`robot_error_reports` に `deleted_at` は無い★(`prisma/schema.prisma:309-353`)ので
* 論理削除で絞らない。`clean_timetables.inactive` でも絞らない(第1〜3歩と同じ方針)。
*
* ★フィルタ・ソート・ページングはここでやらない★(`DataSource` の設計方針)—
* `app/api/prechecks/route.ts` が `sortPrechecks()`(D-052)等で行う。
*
* ★★性能(指示書AC 段階4 で解決)★★: 以前は引数を取らず ★全期間・全件★ を読んでいた
* (`robot_error_reports` 49 + `clean_timetables` 1,044 + `clean_status_histories` 538
* = ★実測 1,631行★。指示書AC B-3)。★★いまは日付範囲・施設ID・本清掃
* (`precheck = 0`)を SQL に降ろしている★★。★索引は最初から張られている★
* (`@@index([start_at])` / `@@index([facility_id])` / `@@index([parent_id])`)。
*
* ★★2種類の行を**同じ期間**で絞る★★(指示書AC 段階4。★片方だけ絞ると静かに壊れる★):
* ★どちらの行も期間の基準は「本清掃行の `start_at`」★(`PrecheckDbQuery` の型コメント)
* なので、★★本清掃行を1回絞れば両方に効く★★。引く順は
* ★本清掃行(期間・施設で絞る)→ その子の事前チェック行(`parent_id`)→
* そのエラー行(`clean_timetable_id`)★。
* ★★エラー行を `errored_at` で絞らないこと★★ — `scheduled_at` に入るのは
* ★親の本清掃行の `start_at`★(指示書S 3-2 項5)なので、
* `filterPrechecksByDate()`(`scheduled_at` で絞る)と食い違う。
*
* ★★連続日数は関係しない★★ — この API は連続日数を返さないので、
* `cleanLogs()` のような「全期間を読み続ける1本」(C-2 / C-3)は要らない。
*/
async prechecks(query: PrecheckDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★★`query.date_from` の生の文字列から `Date` を作らないこと★★ — DB の DATETIME は
// 日本時間の壁時計値なので9時間ずれる(指示書AC C-6)。★変換は `dbDateRange()` の1箇所★。
// ★★ローカルDB(深夜0〜3時のデータしか無い)では付け忘れを検出できない★★ので、
// 合成データのテストで固定している(`app/api/prechecks/__tests__/route.test.ts`)。
const range = dbDateRange(query.date_from, query.date_to);
// ★★本清掃行だけを、期間・施設で絞って引く★★(`cleanLogs()` と同じ作法)—
// ★★SQL 側の `where` から `precheck` を外さないこと★★。外すと事前チェック行が
// 下の `launch_failed` のループに入り、★1回の清掃につき3件の出発失敗★ が並ぶ
// (`section.robots[0]` が同じなので例外にもならない)。
// ★予定は2表から読む(指示書AU 段階3b。`findTimetables()`)★
const timetables = await findTimetables(prisma, {
where: {
precheck: CLEAN_TIMETABLE_PRECHECK.main,
// ★`gte` も `lt` も無いときは `start_at` のキーごと付けない★
...(range.gte || range.lt ? { start_at: range } : {}),
// ★★`!== null` で分岐すること★★(★罠1★)—
// ★`in: undefined` は**条件が消えて全件を返す**★(生成SQL が `WHERE 1=1`)。
// ★`in: []` なら `WHERE 1=0` で0件★=「分類で絞ったが1件も当たらなかった」。
// ★★`query.facility_ids?.length ? ... : {}` と書かないこと★★
// (`cleanLogs()` と同じ理由。0件ヒットが全件表示に化ける)。
// ★`clean_timetables.facility_id` は `BigInt` 列なので1件ずつ包む★
...(query.facility_ids !== null
? { facility_id: { in: query.facility_ids.map((id) => BigInt(id)) } }
: {}),
},
include: {
facility: true,
// 出発失敗の行の `serial_number` に必要(エラー行が無いので報告側から取れない)
section: { include: { robots: { where: { deleted_at: null } } } },
},
});
// BigInt はキーとして === で比較できないので文字列化して引く
const key = (id: bigint) => id.toString();
// ★★`timetableById` は**本清掃行だけ**を持つ★★(以前は全行を持っていた)—
// 下の `mainRow` の引き当てはこの Map で行う。★期間の外の本清掃行は入らない★ので、
// ★親が期間外のエラー行は結果に出ない★(=2種類の行が同じ期間で絞られる)。
const timetableById = new Map(timetables.map((row) => [key(row.id), row]));
// ★引いた本清掃の id で、関連する行を引き直す★(★全件読みに戻さないこと★)。
// ★`in` の要素数の上限と分割は `chunkForInClause()` に閉じ込めてある★(§E-5)。
// ★塊が0個(本清掃が0件)ならクエリを1本も撃たない★。
const mainIdChunks = chunkForInClause(timetables.map((row) => row.id));
// ★事前チェック行は `parent_id` で別クエリに分けて引く★(`cleanLogs()` と同じ。指示書AC D-4)—
// ★★1回のクエリで `precheck` 混在のまま引いて、あとでメモリで分けるやり方に
// 戻さないこと★★(それだと期間の絞り込みが SQL で効かない)。
// ★読む列は3つだけ★ — 下で使うのは `id` / `parent_id` / `precheck` のみ。
// ★★2表から読み、全塊を合わせてから1回だけ id で重複を落とす★★(`cleanLogs()` と同じ。指示書AU 段階3b-3)
const precheckRows = await findTimetablesByChunks(prisma, mainIdChunks, (ids) => ({
where: { parent_id: { in: ids } },
select: { id: true, parent_id: true, precheck: true },
}));
const precheckRowById = new Map(precheckRows.map((row) => [key(row.id), row]));
const [errorReportChunks, reportChunks] = await Promise.all([
// ★エラー行は**事前チェック行の id**でしか引かない★ —
// ★本清掃行を指すエラー行は `launch_failed` に読み替えない★(D-104)ので要らない。
// ★facility は FK があるので include してよい(落とし穴1 は FK が無い列の話)★
Promise.all(
chunkForInClause(precheckRows.map((row) => row.id)).map((ids) =>
prisma.robotErrorReport.findMany({
where: { clean_timetable_id: { in: ids } },
// ★`robot_id` / `robot` を読まない(NULL の行で落ちないため。指示書AU 段階0-4)★
select: ROBOT_ERROR_PRECHECK_ROW_SELECT,
})
)
),
// ★清掃結果(`clean_task_reports`)は**本清掃の id**でしか引かない★
// (下で本清掃行の id でしか Map を引かない)。★取得元は指示書AQ 決定2/3 で差し替えた★
Promise.all(
mainIdChunks.map((ids) =>
prisma.cleanTaskReport.findMany({
where: { clean_timetable_id: { in: ids } },
})
)
),
]);
const errorReports = errorReportChunks.flat();
const taskReports = reportChunks.flat();
// ★★`cleanLogs()` と**同じ関数**でレポートを1件に絞る★★(D-107。落とし穴2 / 指示書AQ §1-4)—
// 以前はここが `new Map(histories.map(...))`(=DB返却順の後勝ち)で、
// `cleanLogs()` の「ended_at 最新」と規則が違っていた。
// ★いまの判定は「行があるか」だけなのでどの行を選んでも答えは同じだが★、
// ★規則を1箇所にしておかないと、判定材料が増えた瞬間に `/detail-log` と `/alerts` で
// 出発失敗の件数が食い違う★(D-103 が禁じた状態)
const reportByTimetableId = latestReportByTimetableId(taskReports);
const items: PrecheckItem[] = [];
// ---- 1. 事前チェック行のエラー → severity: "precheck"
for (const report of errorReports) {
// ★nullable(清掃タイムテーブルに紐づかないエラーがありうる。`prisma/schema.prisma:322`)★
// ★事前チェックのアラートではないので対象外にする★(指示書S 3-2 項6 の推奨)。
// `scheduled_at`(本清掃の予定時刻)を決められないため、そもそも行を作れない。
// ★★`in: [...]` で引いているので SQL 側でも落ちるが、この行は残す★★ —
// 型の絞り込み(`bigint | null` → `bigint`)に要るうえ、規則を1箇所で読めるようにする。
if (report.clean_timetable_id === null) continue;
// ★★引くのは**事前チェック行**の Map★★(本清掃行の Map ではない)—
// `precheckRows` は `parent_id: { in: 期間で絞った本清掃の id }` で引いた行なので、
// ★ここに当たる時点で「親が期間内」が保証される★。
const row = precheckRowById.get(key(report.clean_timetable_id));
// ★FK が無いため、参照先が消えている/存在しないことが起こりうる★
// 例外にすると1行の不整合で `/alerts` 全体が落ちるので、対象外として飛ばす
if (!row) continue;
// ★本清掃行を指すエラーは `launch_failed` に読み替えない(D-104)★
// 出発失敗は下の 2. で本清掃行のレポートの有無から作る。ここで拾うと二重計上になる。
// `precheckMinutesFromCode()` は本清掃コードに対して null を返す
const precheckMinutes = precheckMinutesFromCode(row.precheck);
if (precheckMinutes === null) continue;
// ★`scheduled_at` は事前チェック行の `start_at` ではなく親の本清掃行の値★(指示書S 3-2 項5)
// 画面は「予定」として清掃の開始時刻を出すので、60分前の時刻を入れてはいけない。
// ★★期間の基準もこの値★★(指示書AC 段階4。`errored_at` ではない)— だから
// ★上の SQL は本清掃行を期間で絞り、そこから子を辿っている★。
const mainRow = timetableById.get(key(row.parent_id));
if (!mainRow) {
// 事前チェック行の `parent_id` は必ず本清掃行を指す設計(D-092 項1)。
// 指していなければデータが構造的に壊れているので、静かに別の時刻を出さず気づけるようにする。
// ★★段階4 以降、`precheckRows` は `parent_id: { in: 本清掃の id }` で引いているので
// 構造上ここには来ない★★。★それでもこの行を消さないこと★ —
// 引き方を変えたときに「親が見つからない行を静かに落とす」状態へ戻さないための歯止め。
throw new Error(
`[db] prechecks: clean_timetables(id=${row.id}) の parent_id=${row.parent_id} に対応する本清掃行がありません`
);
}
// ★行の組み立ては `precheckItemFromErrorRow()` の1本★(指示書AT 段階4-1)—
// `precheckItemById()`(PATCH の前後の読み取り)も同じ関数を使う。★ここに書き戻さないこと★
// ★親の本清掃のレポートは上で本清掃の id でまとめて引いた Map から取る★
// (`main_run_succeeded`。D-171。★1行ずつクエリしないこと★=N+1)
items.push(
precheckItemFromErrorRow(
report,
precheckMinutes,
mainRow,
reportByTimetableId.get(key(mainRow.id))
)
);
}
// ---- 2. 出発しなかった本清掃 → severity: "launch_failed"(D-103 / D-104)
// ★`now` は1回だけ作って全行で使い回す★(行ごとに作ると境界の行の扱いがぶれる)
const now = new Date();
for (const row of timetables) {
// ★★`precheck === main` の絞り込みは SQL に降ろした★★(指示書AC 段階4)ので、
// ここに残っているのは ★未来の予定を外す条件★ と ★出発したかの判定★ だけ。
// ★★まだ予定時刻が来ていない清掃は「出発しなかった」とは言えないので対象外★★
// `clean_timetables` には1週間分の予定がまとめて登録される(ユーザー回答 2026-09-10)ため、
// これを外さないと最大7日先までの予定が全部「出発失敗・未対応」として並ぶ。
// ★猶予(`LAUNCH_GRACE_MINUTES`)は足さない★(D-091 項1)。判定材料は `start_at` だけ
if (isFutureSchedule(fromDbJst(row.start_at), now)) continue;
// ★`cleanLogs()` の `departed` と同じ判定を共有する★(D-103 項1 / D-107)—
// ★★出発失敗 = 対応する `clean_task_reports` の行が無い★★(指示書AQ §1-1)。
// ★「レポートが無ければスキップ」を足さないこと★(D-107 設計3-C)—
// レポートの行が無い行は `notExecutedFromReport()` が true を返すので、
// そのまま `launch_failed` になる(★消さずに画面へ出す★。fail-loud)。
// ★`clean_timetable_id` が NULL のレポートしか無い清掃もここに来る★(指示書AQ 罠1 / 決定5)
const mainReport = reportByTimetableId.get(key(row.id));
if (!notExecutedFromReport(mainReport)) continue;
// ★結果待ち(レポートがまだ届いていないだけかもしれない清掃)は出発失敗として出さない★
// (表2 C9。ユーザー判断 2026-10-07。D-173)。判定は `cleanLogs()` の `result_pending` と同じ
// `isResultPending()` の1本(★D-091 項1 はこの件に限りユーザー判断で上書き★)。
// ★確定した出発失敗(待ち時間を過ぎた)は今までどおり出す★
if (
isResultPending({
startAt: fromDbJst(row.start_at),
endAt: fromDbJst(row.end_at),
now,
hasReport: false,
})
) {
continue;
}
// ★P1は1清掃エリア=1ロボットの前提(CLAUDE.md §8)★。`cleanLogs()` と同じ作法で
// 0台なら静かに空文字を返さず例外にする
const robot = row.section.robots[0];
if (!robot) {
throw new Error(
`[db] prechecks: clean_timetables(id=${row.id}) の section(id=${row.section_id}) にロボットが見つかりません。P1は1清掃エリア=1ロボットの前提です。`
);
}
const scheduledAt = jstIsoFromDb(row.start_at);
items.push({
// ★本清掃行の `clean_timetables.id`★(D-104)— `CleanLogItem.id` と同じ値なので、
// 清掃詳細ログ(F-02)とアラート(F-03)に同じ清掃について同じ番号が出る。
// ★`BigInt` を `Number()` に通してから文字列化する★(`PrecheckItem.id` は string)
id: String(Number(row.id)),
severity: "launch_failed",
// ★出発失敗は「何分前のチェックか」を持たない★(D-105)
precheck_minutes: null,
serial_number: robot.serial_number,
facility_id: Number(row.facility_id),
facility_name: row.facility.facility_name,
scheduled_at: scheduledAt,
// ★エラー行が0件のときの値(`Standby.`)★ — 自分で文字列を書かず、
// 既存の純粋関数に空配列を渡して得る(D-098 / D-104)
robot_status: robotStatusFromErrors([]),
// ★予定時刻をそのまま検知時刻にする★ —
// エラー行が無いので `errored_at` が存在せず、`detected_at` は nullable でない。
// ★「予定+猶予30分」を当方で計算しない★(D-091 項1。`LAUNCH_GRACE_MINUTES` を読まない)
// ため、予定時刻を採る。★fixtures も同じ(`lib/fixtures/prechecks.ts` の
// `launch_failed` 2件は `detected_at === scheduled_at`)★
detected_at: scheduledAt,
// ★`unresolved` 固定★(D-104)— エラー行が無いので状態を持てない。
// ★「その他」「不明」のような語は使わない★(D-089 項3)
resolution_status: "unresolved",
// ★すべて null★(D-104)— 備考も日時も存在しない。
// ★将来「対応状況の更新機能」を作るときは書き込み先が無い★(申し送り。D-105)
// ★`PATCH /prechecks/{id}/status` は出発失敗の id に 404 を返す(指示書AT §3-3)★
responded_at: null,
resolved_at: null,
note: null,
battery_level: null,
// ★出発失敗は常に false★(D-171 の M6)— レポート行が無いので `mainRunSucceededFromReport()` が false を返す。
// ★`false` を直書きせず同じ関数を通す★(成功の定義を1箇所に保つ)
main_run_succeeded: mainRunSucceededFromReport(mainReport),
});
}
return items;
},
/**
* ★条件は `DataSource.precheckItemById()` の docstring★(指示書AT §3-3)。
* ★`prechecks()` が事前チェック行を作る条件と同じ★ — 条件を自前で書き直さず、
* 同じ関数(`precheckMinutesFromCode()` / `precheckItemFromErrorRow()`)を使う。
*/
async precheckItemById(id: string) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// 1. `error_id` は UNIQUE(`prisma/schema.prisma` の `RobotErrorReport`)。主キーは無い
const report = await prisma.robotErrorReport.findUnique({
where: { error_id: id },
// ★`prechecks()` と同じ列。`robot_id` / `robot` を読まない(指示書AU 段階0-4)★
select: ROBOT_ERROR_PRECHECK_ROW_SELECT,
});
if (!report) return null;
// ★★DB の照合順序は大小文字を区別しない(ci)★★ — `rer-1` で `RER-1` が当たる。
// ★当たり外れは JS の `===` で決める★(D-4。`dsDeviceExists()` と同じ)。
// ★書き込みの WHERE も同じ照合順序なので、ここで弾かないと別の文字列で同じ行を書き換えられる★
if (report.error_id !== id) return null;
// 2. 予定に紐づかないエラー(N-5 の D-S制御盤・セキュリティの行を含む)は対象外
if (report.clean_timetable_id === null) return null;
// 3. 事前チェック行であること(本清掃行を指すエラーは `launch_failed` に読み替えない。D-104)
// ★予定は2表から引く(本表 → 無ければ履歴。指示書AU 段階3b-2 の 5)★ —
// ★履歴の予定に紐づくエラー行も PATCH で書けるようになる(§3b-7 Q4。実在するアラートなので、それでよい)★
const row = await findTimetableById(prisma, report.clean_timetable_id, {
id: true,
parent_id: true,
precheck: true,
});
if (!row) return null;
const precheckMinutes = precheckMinutesFromCode(row.precheck);
if (precheckMinutes === null) return null;
// 4. 親の本清掃行(`scheduled_at` に使う)。★無ければ例外★(`prechecks()` と同じ。データが構造的に壊れている)
// ★旧: `findFirst({ id: parent_id, precheck: 本清掃 })`★ — 2表から id で引き、`precheck` は JS で確かめる
// (条件は同じ。★本表に同じ id があれば本表が勝つ★ので、本表の行が本清掃でなければ履歴を見ずに例外)
const parentRow = await findTimetableById(prisma, row.parent_id, {
id: true,
precheck: true,
start_at: true,
});
const mainRow =
parentRow && parentRow.precheck === CLEAN_TIMETABLE_PRECHECK.main ? parentRow : null;
if (!mainRow) {
throw new Error(
`[db] precheckItemById: clean_timetables(id=${row.id}) の parent_id=${row.parent_id} に対応する本清掃行がありません`
);
}
// 5. 親の本清掃のレポート(`main_run_succeeded`。D-171)。★`prechecks()` と同じ関数で1件に絞る★
// (`latestReportByTimetableId()`。★`orderBy` で別の順序キーを書かないこと★。D-107)
const mainReports = await prisma.cleanTaskReport.findMany({
where: { clean_timetable_id: mainRow.id },
});
const mainReport = latestReportByTimetableId(mainReports).get(mainRow.id.toString());
return precheckItemFromErrorRow(report, precheckMinutes, mainRow, mainReport);
},
async updatePrecheckErrorColumns(errorId: string, patch: PrecheckStatusPatch) {
// ★書き込み用の接続★(`DATABASE_WRITE_USER` / `DATABASE_WRITE_PASSWORD`。未設定なら参照用と同じ接続。指示書AT §3-2)
const { getWritePrisma } = await import("./prisma");
const client = await getWritePrisma();
const { matched } = await writeRobotErrorColumns(client, errorId, patch);
return { matched };
},
/**
* エラー一覧(指示書AW 段階3-5)。★2段階で読む★(D8)。
*
* ★なぜ SQL で並べ替え・ページ分けしないのか★ — 並びの基準は `errored_at ?? created_at`(D3)だが、
* Prisma の `orderBy` は ★列ごとの並べ替えしか書けず `COALESCE` を書けない★(生SQL は禁止。`CLAUDE.md` §3)。
* `[{ errored_at: { sort: "desc", nulls: "last" } }, { created_at: "desc" }]` で近似すると、
* ★`errored_at` が NULL の行(表1 E7)が検知時刻に関係なく最後に回る★(例外もテスト失敗も出ない)。
* そこで 1段目 = キーだけを SQL で `status IN`・`facility_id IN`・期間に絞って全件読み、
* アプリで ★絞り込みの正(`filterSortErrorReportKeys()`)→ 並べ替え → ページ分け★、
* 2段目 = ★そのページの `error_id` だけ★ 列を足して読み直す(最大 `PAGINATION.maxPerPage` 件)。
*/
async errorReports(query: ErrorReportDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// 1段目: キーだけ(★SQL で status / 施設 / 期間を絞る。並べ替え・ページ分けはしない★)。
// ★`robot_id` / `robot` を読まない(NULL の行で落ちないため。指示書AU 段階0-4)★
const keyRows = await prisma.robotErrorReport.findMany({
where: errorReportListWhere(query),
select: ROBOT_ERROR_LIST_KEY_SELECT,
});
const keys = filterSortErrorReportKeys(keyRows.map(errorReportKeyFromRow), query);
const pageIds = errorReportPageIds(keys, query);
// ★0件なら2段目も予定も撃たない★
if (pageIds.length === 0) return { items: [], total: keys.length };
// 2段目: そのページの行だけ列を足して読む(`in` の分割は `chunkForInClause()`)
const rows = (
await Promise.all(
chunkForInClause(pageIds).map((ids) =>
prisma.robotErrorReport.findMany({
where: { error_id: { in: ids } },
select: ROBOT_ERROR_LIST_ROW_SELECT,
})
)
)
).flat();
// 予定は2表から id で(D5。★同じ id は本表が勝つ★)。★id が0件なら塊も0個でクエリを撃たない★
const timetableIds = [
...new Set(
rows.flatMap((row) => (row.clean_timetable_id === null ? [] : [row.clean_timetable_id.toString()]))
),
].map((id) => BigInt(id));
const timetables = await findTimetablesByChunks(prisma, chunkForInClause(timetableIds), (ids) => ({
where: { id: { in: ids } },
select: { id: true, precheck: true },
}));
// ★pageIds の順に並べ、2段目で見つからない error_id は飛ばす(指示書AW 4-15。理由は関数の docstring)★
return { items: errorReportItemsInPageOrder(pageIds, rows, timetables), total: keys.length };
},
/** ★条件は `DataSource.errorReportById()` の docstring★(指示書AW 段階3-5) */
async errorReportById(id: string) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// `error_id` は UNIQUE。★一覧の2段目と同じ列(`robot_id` / `robot` を読まない)★
const report = await prisma.robotErrorReport.findUnique({
where: { error_id: id },
select: ROBOT_ERROR_LIST_ROW_SELECT,
});
if (!report) return null;
// ★★DB の照合順序は大小文字を区別しない(ci)★★ — `rer-1` で `RER-1` が当たる。
// ★当たり外れは JS の `===` で決める★(D-4。`precheckItemById()` と同じ)。
// ★書き込みの WHERE も同じ照合順序なので、ここで弾かないと別の文字列で同じ行を書き換えられる★
if (report.error_id !== id) return null;
// 予定は本表 → 無ければ履歴(`findTimetableById()`。本表が勝つ。D5)
const timetable =
report.clean_timetable_id === null
? undefined
: ((await findTimetableById(prisma, report.clean_timetable_id, { id: true, precheck: true })) ??
undefined);
return errorReportItemFromRow(report, timetable);
},
async updateErrorReportColumns(errorId: string, patch: PrecheckStatusPatch) {
// ★`updatePrecheckErrorColumns()` と同じ中身★ — 書き込み用の接続 → `writeRobotErrorColumns()`(`updateMany` の1文)。
// ★`updatePrecheckErrorColumns()` の挙動を変えないため、共通化せずに並べて置く★(指示書AW D12 と同じ考え方)
const { getWritePrisma } = await import("./prisma");
const client = await getWritePrisma();
const { matched } = await writeRobotErrorColumns(client, errorId, patch);
return { matched };
},
/**
* D-S制御盤の一覧(F-06)。★最終通信(`last_reported_at`)は `groupBy` で取る★。
*
* ------------------------------------------------------------------------
* ★★なぜ `control_reports` の入れ子 include をやめたか(指示書AH 段階1 / D-1)★★
* ------------------------------------------------------------------------
* 以前はこう書いていた:
* `include: { control_reports: { orderBy: { reported_at: "desc" }, take: 1 } }`
*
* ★★この `take: 1` は1文字もSQLに出ない★★(★実測★)。Prisma は
* ★制御盤ぶんの `control_reports` を**全行**取り寄せてから、アプリ側(JS)で1件に切っている★。
* ★`relationLoadStrategy`(LATERAL JOIN)は Prisma 7.10.0 / MySQL adapter に存在しない★ので
* 書き換えでは回避できない。
*
* ★★破綻の性質は「遅い」ではなく「メモリ」★★(実測):
* - 100万行 … 8.6秒 / ヒープ 2.7GB
* - ★300万行 … `JavaScript heap out of memory` でプロセス即死★
* Node の既定ヒープ上限は 4,288MB(実測)で、★想定1,100万行はその4倍★。
* ★`groupBy` なら戻りは制御盤数(実測11〜12行)で固定★=行数総量に依存しない。
* ★往復数は増えない★(include も裏で複数本発行していた)。
*
* ★画面が使うのは `reported_at` 1個だけ★(`DsDevice.last_reported_at`。
* `d-watch-api.yaml` も `components/features/ds-logs/DsLogScreen.tsx` も同じで、
* フィルタ・ソート・サマリはどれも `control_reports` を見ていない)。
*
* ------------------------------------------------------------------------
* ★★なぜ時間で足切り(`reported_at >= X`)しないか(D-2。★足さないこと★)★★
* ------------------------------------------------------------------------
* ★`reported_at` に索引が無い★ため、`EXPLAIN` 実測で ★`key_len` が `control_id` の
* 8バイトのまま増えず、走査行数も足切り無しと同じ★だった(=後段フィルタでしかない)。
* ★`groupBy` の戻りはもともと制御盤数なので、足切りを足す意味が無い★。
* ★★そのうえ新しいバグを作る★★ — ★足切りの外にしか心拍が無い制御盤は最終通信が空欄になり、
* 「いつから切れているか」が見えなくなる★(通信断の判定自体は `controls.status` なので狂わない)。
*
* ------------------------------------------------------------------------
* ★★なぜ `MAX(id)` を使わないか(D-3。★変えないこと★)★★
* ------------------------------------------------------------------------
* InnoDB の二次索引には主キーが暗黙に付くので `control_reports_control_id_foreign` は
* 物理的に `(control_id, id)` であり、★`MAX(id)` なら `Using index for group-by` が効く★
* (実測: `type=range` / `rows=3`。`MAX(reported_at)` は `type=index` / `rows=14`)。
* ★★しかし `id` の大小順と `reported_at` の大小順が一致する保証はどこにも無い★★ —
* 書き込むのは TeamWorks のデータ収集処理で、★その順序は仕様として確認していない★
* (`CLAUDE.md` §7「仕様が不明な箇所を推測で実装しない」)。
* ★確認が取れるまで `MAX(reported_at)` を使うこと★。
* ★根本的な手当ては複合索引 `(control_id, reported_at)` の依頼★(`docs/handoff.md`)。
*/
async dsDevices() {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★deleted_at IS NULL でのみ絞る。inactive では絞らない★
// inactive は「無効化」であって削除ではなく、画面が見せ分ける可能性がある
// (lib/schedules/query.ts:108-112 に前例あり。仕様が未確定な箇所。指示書P 禁止事項10 / D-086)。
const controls = await prisma.control.findMany({
where: { deleted_at: null },
include: { facility: true },
});
// ★制御盤ごとの最終通信だけを取る(行は運ばない)★
// ★`control_id IN (...)` は制御盤数ぶん(実測11〜12)★。
// ★200台を超えたら実行計画を測り直すこと★(`docs/handoff.md` の申し送り)。
const latest = await prisma.controlReport.groupBy({
by: ["control_id"],
where: { control_id: { in: controls.map((c) => c.id) } },
_max: { reported_at: true },
});
// ★★キーは `String()` で揃えること★★(★罠1★)—
// `BigInt` をそのまま Map のキーにすると `1n` / `"1"` / `1` が別のキーになり、
// ★全制御盤が「心拍0件」に見えて例外になる★。
const byControl = new Map(latest.map((g) => [String(g.control_id), g._max.reported_at]));
return controls.map((row): DsDevice => {
const lastReportedAt = byControl.get(String(row.id));
if (!lastReportedAt) {
// ★シードは全controlsにcontrol_reportsを1件以上入れる想定(指示書P 1-3)★
// 0件が来た場合は黙って空文字を返すより、例外で気づけるほうを選ぶ
// (★「一覧に出るのに最終通信が無い」の検知。メッセージも含めて変えないこと★)
throw new Error(
`[db] dsDevices: controls(id=${row.id}) に control_reports が1件もありません`
);
}
return {
serial_number: row.serial_number,
facility_id: Number(row.facility_id),
facility_name: row.facility.facility_name,
// ★controls.status(0/1) → connection_status("ok"/"error")。D-065 Q1-2★
connection_status: connectionStatusFromControlStatus(row.status),
// ★control_reports.reported_at は TIMESTAMP だが、実測(指示書Y 段階3)で
// DATETIME と同じ往復(9時間ずれ)をすることを確認した(ローカルの
// @@system_time_zone が実質JST相当のため)。DATETIME と同じ補正でよい★
last_reported_at: jstIsoFromDb(lastReportedAt),
// ★note は常に null を返す。DBに対応カラムが無い(D-065 Q1-1)ため、実値は
// app/api/ds-devices/route.ts:75-78 がJSONストア(data/settings/ds-device-notes.json)から
// マージする。ここで埋めようとしないこと(後任が「実装漏れ」と誤解しないように明記)★
note: null,
};
});
},
/**
* ★シリアル1件の実在確認★(指示書AH 段階2 / D-4)。
*
* ------------------------------------------------------------------------
* ★★なぜ `dsDevices()` を呼ばないか★★
* ------------------------------------------------------------------------
* 以前は `PATCH /ds-devices/{serial_number}/note` が `db.dsDevices()` を呼んでいた。
* ★シリアル1件の実在を確かめるためだけに、全制御盤+全施設(+当時は全心拍)を読む★。
* ★実在確認に施設名も最終通信も要らない★。
* ★さらに、無関係な1台に心拍が0件だと例外が飛んで**全台の備考保存が500になる**★
* (段階1 で心拍は読まなくなったが、★経路を分けること自体に価値がある★)。
*
* ------------------------------------------------------------------------
* ★★当たり外れは JS の `===` で決める(★罠4★ / D-4。★変えないこと★)★★
* ------------------------------------------------------------------------
* ★DBの既定の照合順序は大小文字を区別しない(ci)★。SQL の一致をそのまま答えにすると:
* `ds-001` が `DS-001` に当たって 404 → 200
* → ★保存キーはリクエストの生文字列 `ds-001`★(`lib/settings/ds-device-notes.ts`)
* → 一覧は `notes["DS-001"]` で引く
* → ★★保存されたのに一生表示されない孤児行が残る★★
* つまり ★実在チェックが防ぐはずの事故が、実在チェックのせいで起きる★。
* ★★SQL には絞り込みだけさせ、最後は `row.serial_number === serialNumber` で判定する★★。
*
* - ★`findFirst` ではなく `findMany`★ — ci 照合で複数行が当たったとき、
* `findFirst` が「別の行」を返して ★偽404★ になる
* - ★`select` は `serial_number` だけ★(実在確認に他の列は要らない)
* - ★`take` は付けない★(★罠8★。段階1 と同じ「SQL に降りない」問題を踏みうる)
* - ★`deleted_at: null` を `dsDevices()` と揃える★(★罠7★。上の `dsDeviceExists()` の
* インターフェース側コメント参照)
*
* ★`controls.serial_number` に索引は無い★が、★`controls` は実測11〜12行★なので
* 索引の依頼はしない(`docs/handoff.md` の申し送り対象外)。
*/
async dsDeviceExists(serialNumber: string) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
const rows = await prisma.control.findMany({
where: { deleted_at: null, serial_number: serialNumber },
select: { serial_number: true },
});
// ★ここが「当たり外れ」を決める唯一の場所★(SQL の一致に任せない。D-4)
return rows.some((row) => row.serial_number === serialNumber);
},
/**
* ★このメソッドは disconnected(制御盤の生死報告なし)しか返しません★。
* ★理由★: `security_off`(セキュリティ解除されたまま)に対応するカラムが
* `security_statuses` / `security_status_histories` に無いためです(D-090 項3-9)。
* `DsAlert` が要求する `id` / `device_serial` / `resolution_status` / `responded_at` /
* `resolved_at` / `memo` の6フィールドに対応するカラムが両テーブルどちらにも存在しません。
* また `security_statuses.reported_at` は `@updatedAt` のため、`detected_at`
* (いつからセキュリティが解除されたままか)も取れません(D-090 項3-10)。
* ★帰結★: `/ds-alerts` の件数が fixtures より少なくなり(security_off分が返らない)、
* `/dashboard` のセキュリティバナー(`lib/dashboard/aggregate.ts` の `computeSecurityBanner()`)が
* 出なくなります。★これは意図した挙動であり、実装漏れではありません★。
* `security_off` 側は第3歩(`docs/decisions.md` D-090 項2 の分割表)で扱います。
*/
async dsAlerts() {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★control_error_histories には deleted_at が無い → 絞らない(段階3-5)★
// controls 側の deleted_at は結合先として絞ってよい
const rows = await prisma.controlErrorHistory.findMany({
where: { control: { deleted_at: null } },
include: {
control: true,
facility: true,
section: true,
},
});
return rows.map((row): DsAlert => ({
id: Number(row.id),
// ★control_error_histories は制御盤異常(生死報告なし)しか持たない(D-067 項1)★
alert_type: "disconnected",
device_serial: row.control.serial_number,
facility_id: Number(row.facility_id),
facility_name: row.facility.facility_name,
section_name: row.section.section_name,
detected_at: jstIsoFromDb(row.occurred_at),
// ★control_error_histories.status(0/1/2) → resolution_status。D-065 Q1-3★
resolution_status: resolutionStatusFromControlErrorStatus(row.status),
responded_at: row.responded_at ? jstIsoFromDb(row.responded_at) : null,
resolved_at: row.resolved_at ? jstIsoFromDb(row.resolved_at) : null,
// ★書き込み許可4カラムの1つ(D-042)★
memo: row.comment,
}));
},
/**
* ホールディングス一覧(`GET /groups`。指示書AK 段階1)。
*
* ★`deleted_at IS NULL` でのみ絞り、`inactive` では絞らない★(`facilities()` と同じ判断)。
* ★並べ替えはルート側(`sortGroups()`)★ — 規則の「正」を純粋関数に置いたまま
* (指示書AC C-5。ここでの `orderBy` はその写しになるので書かない)。
*/
async groups() {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
const rows = await prisma.group.findMany({ where: { deleted_at: null } });
return rows.map(
(row): Group => ({
id: Number(row.id),
group_name: row.group_name,
})
);
},
/**
* 屋号一覧(`GET /customers`。指示書AK 段階1)。
*
* ★★`contract` を読んでいないこと★★ — `customers.contract` は削除依頼済み
* (D-010 / 落とし穴5)。そもそも `prisma/schema.prisma` の `model Customer` に列が無い。
*
* ★`group_id` の 0 → `null` は `groupIdFromDb()` に任せる★(D-011 / 落とし穴3)—
* ★ここに `=== 0` を書かないこと★。`BigInt` のままだと `0n === 0` が `false` で
* 静かに外れるので、先に `Number()` に落としてから渡す。
*
* ★★`orderBy` を付けてある★★(指示書AL 段階3 §4-4。★`groups()` と違う点★)—
* `GET /customers` はルート側の `sortCustomers()` で並べ替えるので、そちらだけを見ると
* ここの `orderBy` は写しに見える。★だが `GET /reports/kpi` の 422 は
* `db.customers()` の並びをそのまま `details.customer_ids` に流す★ので、
* ★無いと候補の並びがDB任せになる★。★テストはスタブの順序で通ってしまい気づけない★。
*/
async customers() {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
const rows = await prisma.customer.findMany({
where: { deleted_at: null },
orderBy: { id: "asc" },
});
return rows.map(
(row): Customer => ({
id: Number(row.id),
customer_name: row.customer_name,
group_id: groupIdFromDb(Number(row.group_id)),
})
);
},
async facilities() {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★deleted_at IS NULL でのみ絞る。inactive では絞らない★
// inactive は「無効化」であって削除ではなく、画面が見せ分ける可能性がある
// (lib/schedules/query.ts:108-112 に前例あり。仕様が未確定な箇所。指示書M 3-4 / D-086)。
const rows = await prisma.facility.findMany({
where: { deleted_at: null },
include: {
robots: {
where: { deleted_at: null },
include: { device: true },
},
// ★所属(屋号・ホールディングス)は施設の行と一緒に読む★(指示書AK 段階5 §8-2)。
// ★施設マスタは実測12件なので N+1 にはならない★(`loadFacilityGroupIndex()` と同じ判断)。
// ★`deleted_at` で絞らないこと★ — 屋号側を絞ると、論理削除された屋号に属する施設が
// 静かに落ちる(`lib/db/facility-group-source.ts` の docstring と同じ理由)。
customer: { include: { group: true } },
},
});
return rows.map((row): Facility => {
const { monitoring_enabled, d_security_enabled } = parseContract(row.contract);
// ★0 → null の変換を知っているのは `groupIdFromDb()` だけ★(D-011 / ★落とし穴3★)—
// ★ここに `=== 0` を書かないこと★。`BigInt` のままだと `0n === 0` が `false` で
// 静かに外れるので、先に `Number()` に落としてから渡す。
const group_id = groupIdFromDb(Number(row.customer.group_id));
return {
id: Number(row.id),
facility_name: row.facility_name,
customer_id: Number(row.customer_id),
customer_name: row.customer.customer_name,
group_id,
// ★「所属なし」のときは名前も `null`★ — センチネル(`group_id = 0`)に対応する
// `groups` の行は存在しないので、名前を出す相手がいない。
// `?? null` は保険(FKが緩い環境で参照先を欠いた行が来ても「グループ0」を出さない)。
group_name: group_id === null ? null : (row.customer.group?.group_name ?? null),
prefecture: row.prefecture_name,
address: formatAddress(row.addr1, row.addr2),
phone: row.phone,
manager_name: row.manager_name,
charger_name: row.charger_name,
email: row.email,
monitoring_enabled,
d_security_enabled,
robots: row.robots.map((robot) => ({
id: Number(robot.id),
maker: MAKER_NAMES[robot.device.maker_id] ?? String(robot.device.maker_id),
model: robot.device.model_name,
serial_number: robot.serial_number,
})),
};
});
},
async schedules() {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
const rows = await prisma.cleanSchedule.findMany({
where: { deleted_at: null },
include: {
facility: true,
section: {
include: {
robots: { where: { deleted_at: null } },
},
},
},
});
return rows.map((row): Schedule => {
// ★P1は1清掃エリア=1ロボットの前提(CLAUDE.md §8)★
// 0台の section が来た場合は、静かに空文字を返すより例外で気づけるほうを選ぶ
// (指示書M 4-2)。段階2のシードで施設12(ロボット0台)のスケジュールを
// 構造的に除外しているため、通常運用ではここには到達しない。
const robot = row.section.robots[0];
if (!robot) {
throw new Error(
`[db] schedules: clean_schedules(id=${row.id}) の section(id=${row.section_id}) にロボットが見つかりません。P1は1清掃エリア=1ロボットの前提です。`
);
}
return {
id: Number(row.id),
serial_number: robot.serial_number,
facility_id: Number(row.facility_id),
facility_name: row.facility.facility_name,
weekdays: row.weeks,
weekdays_label: weekdaysLabel(row.weeks),
start_time: formatStartTime(row.start_time),
// ★inactiveの論理は反転している(0=有効)。Boolean(row.inactive)は書かない★
// (lib/schedules/query.ts:15-19 の警告どおり。全件逆になる典型的な地雷)
end_time: formatEndTime(row.end_time),
active: row.inactive === 0,
};
});
},
async inspections() {
return notImplemented("inspections");
},
async inspectionDetail() {
return notImplemented("inspectionDetail");
},
/**
* 要レスキュー店舗(F-01。指示書AI 段階4)。
*
* ★★新しい全期間クエリを書いていない★★ — 入力は
* ★`loadConsecutiveInputs()`(`lib/db/consecutive-source.ts`)の1本★ から取る
* (★4本目の呼び出し元★。`cleanLogs()` / `facilityPerformance()` /
* `continuousProblems()` と同じ1本。D-109 / D-103 の再発防止)。
*
* ★★遡り幅を要レスキュー用に縮めないこと★★(指示書AI §6-2 / §13-7)—
* R-1 は3スケジュール日、R-3 は1スケジュール日で足りるが、
* ★R-2(L3)は既存の遡り幅をそのまま必要とする★。縮めると ★L3 が L1 に化ける★
* (例外もテスト失敗も出ない)。
*
* ★★`date_from` にも `date_to` にも対象日を渡す★★ — 遡りぶんは
* `loadConsecutiveInputs()` が足す(★ここで足さないこと★。3経路で食い違う)。
* ★当該日に予定が無い施設を拾えるのは、この遡りぶんに前日以前の行が入るから★
* (絞り込みは `buildRescueRequired()` が「当該日以下」で行う。指示書AI §6-3)。
*
* ★施設名は `loadFacilityNameIndex()`★(`facilityNameIndex()` と同じ1本)—
* ★`facilities()` で代用しないこと★(あちらは `deleted_at` で絞るので、
* 論理削除された施設が要レスキューから静かに消える。★罠7★)。
*/
async rescueRequired(query: RescueDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★`now` は1回だけ作って両方の読み取りで使い回す★(未来の予定の除外基準を揃える)
const now = new Date();
const [inputs, facilityNames] = await Promise.all([
loadConsecutiveInputs(prisma, now, {
date_from: query.date,
date_to: query.date,
}),
loadFacilityNameIndex(prisma),
]);
// ★判定は fixtures と同じ1本を通す(`USE_FIXTURES` で分岐させない)★
// ★件数の上限は渡さない★(指示書AJ 段階1。該当する施設を全部返す)
// ★★分類の絞り込みは fixtures 版と同じ場所(入力の行)にかける★★(指示書AK 段階4b)。
// ★★`loadConsecutiveInputs()` に `facility_id` の条件を足していない★★のは、
// ★あの1本を `cleanLogs()` / `facilityPerformance()` / `continuousProblems()` と
// 共有しており、引数を増やすと3経路の遡り幅の約束(D-109 の再発防止)に触るため★。
// ★読み込み量は段階4b 以前と同じ(絞り込みで増えていない)★。
// ★★申し送り: SQL に降ろすなら4経路まとめて直すこと★★ —
// ★そのときも `null`(絞らない)と `[]`(0件ヒット)の区別を崩さないこと★。
return buildRescueRequired(
filterRowsByFacilityIds(inputs, query.facility_ids),
facilityNames,
query.date
);
},
/**
* 清掃面積(`GET /clean-area`。指示書AN 段階3 §6-2 / §6-3)。
*
* ------------------------------------------------------------------------
* ★★面積は `groupBy` + `_sum`(行を運ばない)、完了率・実施回数は行を運ぶ★★(指示書AN §2-5 / 指示書AS 段階4)
* ------------------------------------------------------------------------
* ★面積の合計は最大でも施設数ぶん(200行程度)★で、★期間の広さに関係しません★。
* ★集計元は `clean_task_reports`★(指示書AQ 決定2/3。2026-09-30 に `clean_status_histories`
* から差し替えた)。★旧取得元は保持2年で約26万行★と見積もっていた。
* ★★完了率・実施回数の材料だけは行を取り寄せる★★(2026-10-02。D-155 / 指示書AS 段階4)—
* 1回ごとの完了率の上限100・小数第1位の切り捨て(`runCompletionRate()`)は `groupBy` で書けず、
* ★生SQL(`$queryRaw`)は禁止★(`CLAUDE.md` §3)のため。★運ぶのは `clean_timetable_id` が
* NULL でない行の5列だけ★(`facility_id` / `clean_timetable_id` / `clean_area` / `task_area` / `clean_ratio`)。
* ★★期間を指定しないと全期間(保持2年)を運ぶ★★ — 「行を取り寄せてJSで足すと期間を広げた瞬間に
* 破綻する」懸念はこの取り寄せには当てはまる。★取り寄せた行数(`rate_rows`)と所要時間は
* ルート層が `judge.clean_area` ログに出す★(D-156 論点2。実データで重ければ別途手当てする)。
* 予定(本清掃か)は id を集めて `chunkForInClause()` で分けて別クエリで引く(`include` は使わない)。
*
* ★`started_at` に索引が無い★(指示書AN §1-5。第9回 1-4 で依頼中)ため全走査になりますが、
* ★★1本の集計クエリの全走査であり、詳細ログのような「塊ごとに全走査 × N回」ではありません★★。
*
* ★★`include` を使わないこと★★(§6-2 / 落とし穴8)— ★親行の数ぶん `IN` が膨らむ★
* (D-136 で実測済み)。★施設マスタは `loadCleanAreaFacilityMasters()` で1回読み、
* JSで突き合わせる★(突き合わせ自体は `aggregateCleanArea()` の仕事)。
*
* ------------------------------------------------------------------------
* ★★番人(§2-5 / §6-3 / 落とし穴2)★★
* ------------------------------------------------------------------------
* ★1つの `clean_timetable_id` にレポートが2行以上あると `SUM()` が同じ清掃を2回数える★。
* ★数え方(NULL 除外を含む)の正は `countDuplicatedTimetables()`★(指示書AQ §5-3)。
* ★例外もテスト失敗も出ず、数字が大きくなるだけ★なので、件数だけ数えて返します。
*
* ★★`having` で「2行以上のものだけ」をDB側に絞らせること★★ —
* ★絞らないと `clean_timetable_id` ごとに1行(=最大26万行)返ってきます★。
* ★異常が無ければ0行しか運びません★。
* ★★集計と同じ `where` を使うこと★★(対象期間・対象施設に絞る。§6-3)—
* ★全期間・全施設を毎回数え直さない★。
*
* ★★ログはここで出さない★★ — `DataAnomalyLogEvent` の `route` / `method` はルート層なら必ず埋めるもので
* (型は D-178 で任意になったが、ルートを載せられる層で出すのが原則)、
* ★データ層はどのルートから呼ばれたかを知らない★(★不明な情報を書かない★)。
* ★件数を返し、`reportCleanAreaAnomalies()`(`lib/clean-area/anomaly.ts`)を
* ルート層が呼ぶ★(段階2 の `unmatched_facility_ids` と同じ分担)。
*/
async cleanArea(query: CleanAreaDbQuery): Promise<CleanAreaSourceResult> {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★★`date_from` / `date_to` から直接 `Date` を作らないこと★★ — `started_at` は
// DATETIME = ★日本時間の壁時計値★なので9時間ずれる(指示書AC C-6)。
// ★変換は `dbDateRange()` の1箇所★。★どちらも `null` なら絞らない★(§2-3。上限も設けない)。
const range = dbDateRange(query.date_from, query.date_to);
// ★★集計と番人で**同じ `where`** を使う★★(§6-3)— 別々に書くと、
// ★片方だけ期間を絞り忘れても誰も気づけない★。
const where: CleanAreaWhere = {
// ★`gte` も `lt` も無いときは `started_at` のキーごと付けない★(`cleanLogs()` と同じ)
// ★`started_at` は `clean_task_reports` にも同じ名前で在る(NOT NULL)★
...(range.gte || range.lt ? { started_at: range } : {}),
// ★★必ず `!== null` で分岐すること★★(★罠1 / 落とし穴3★)—
// ★`in: undefined` は条件が消えて**全件**、`in: []` は `WHERE 1=0` で0件★。
// ★`query.facility_ids?.length ? ... : {}` と書くと「0件ヒット」が「全件」に化けます★。
// ★`facility_id` は `BigInt` 列なので1件ずつ包む★(`CleanAreaWhere` が `bigint[]` を要求。
// `prechecks()` と同じ作法)
...(query.facility_ids !== null
? { facility_id: { in: query.facility_ids.map((id) => BigInt(id)) } }
: {}),
};
// ★★`groupBy` をここに直書きしないこと★★(指示書AQ §5-3)—
// ★集計元(`clean_task_reports`)と番人の `clean_timetable_id IS NOT NULL` 除外は
// `lib/db/clean-area-source.ts` の2関数にしか無い★。直書きすると除外が落ち、
// ★NULL の行が1グループにまとまって番人が毎回1件を誤報する(偽陽性)★。
const [statsLoad, duplicatedTimetableCount, facilityMasters] = await Promise.all([
// ★面積の合計・実施回数・完了率の材料(`BigInt` の `Number()` 変換と `_sum` の `null` 吸収は
// 向こうで済ませる)★。★取り寄せた行数もログ用に受け取る★(D-156 論点2)
loadCleanAreaFacilityStatsWithRowCount(prisma, where),
// ★番人★: ★2行以上ある `clean_timetable_id` の件数★(★NULL を除いて数える★)
countDuplicatedTimetables(prisma, where),
// ★施設マスタは1回だけ読む★(★`deleted_at` で絞らない理由は
// `lib/db/clean-area-source.ts` の docstring★)
loadCleanAreaFacilityMasters(prisma),
]);
return {
stats: statsLoad.stats,
facilities: facilityMasters,
duplicated_timetable_count: duplicatedTimetableCount,
rate_rows: statsLoad.rate_rows,
};
},
/**
* レポートKPI(全社の4指標 + 前期比)。
*
* ★確定期間: `customer_summaries` をそのまま読む。履歴から再計算しない(D-102)★
* ★当期(今週・今月): 詳細テーブルからその場集計する★(インターフェース定義の
* コメント参照。D-090項14/項15の解決)
*
* ★★性能(指示書AC 段階5 で解決)★★: 以前は引数を取らず ★サマリ全件 + 詳細テーブル全件★ を
* 読んでいた(`customer_summaries` 2 + `clean_timetables` 1,044 +
* `clean_status_histories` 538 + `robot_error_reports` 49 = ★実測 1,633行★)。
* ★★いまは「要求された期間 + その1つ前」の2期間ぶんしか読まない★★。
*/
async reportKpis(query: ReportKpiDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★★期間の範囲を自分で計算しないこと★★(指示書AC 段階5「月跨ぎ週の地雷」)—
// 週は月境界で切られ、★長さが1〜7日で変わる★(D-071 / `CLAUDE.md` §4.7)。
// ★帰属ルールを知っているのは `weekOfMonth()` だけ★なので、範囲は必ず
// `periodRange()` に作らせる。★`null` = その月に存在しない週★(5週しかない月の第6週。
// 当期にはなりえないので当期の集計は走らない)。
const range = periodRange(query.period, query.year, query.month, query.week);
// ★★前期比のために1つ前の期間の行も要る★★(`shiftPeriod()`)—
// ★2行ぶんだけ引く。全件読みに戻さないこと★(指示書AC 段階5)。
// ★★サマリの粒度は `week` が 0 かどうかで判別する★★(`week=0` が月次、
// `month=0 AND week=0` が年次。`CLAUDE.md` §4.7)ので、期間キー3つの一致で
// 粒度まで決まる(`period` を別に渡す必要は無い)。
const prevKey = shiftPeriod(query.period, query.year, query.month, query.week, -1);
const summaryRows = await prisma.customerSummary.findMany({
where: {
// ★★対象顧客で絞る(指示書AK ★段階6★ / §2-1)★★ —
// ★`OR`(当期 or 前期)と**同じ階層**に置くこと★。Prisma は同階層のキーを
// ★AND で畳む★ので、これで「対象顧客 AND(当期 or 前期)」になる。
// ★★`OR` の各要素に `customer_id` を書き足す形にしないこと★★ — 同じ意味だが、
// ★条件が2箇所に散ると、片方だけ直したときに静かに全顧客へ戻る★。
// ★`customer_id` は `BigInt` 列★(`prisma/schema.prisma`)なので包む
// (★`Number` のまま渡すと型が合わない★。`cleanLogs()` の `facility_id` と同じ作法)。
customer_id: BigInt(query.customer_id),
OR: [
{ year: query.year, month: query.month, week: query.week },
{ year: prevKey.year, month: prevKey.month, week: prevKey.week },
],
},
});
// ★★年次(month=0 && week=0)も読む★★(指示書AQ 決定1。2026-09-30 解禁)—
// ★以前はここで年次行を除外していた(「P1では読まない」D-072項4)が、
// 確定期間のサマリに週・月・年の3粒度が入るようになったので filter を外した★。
// ★外せる前提★: `periodOfSummaryRow()` が `month` も見るようになった(指示書AQ 罠2)ので、
// ★年次行が「月次」として混入して `customerSummariesByPeriodKey()` が 500 になることはない★。
// ★`where` は期間キー3つの完全一致なので、週次/月次の要求に年次行が混ざることもない★。
// ★★安全弁は残してある(D-5 / 指示書AH 段階3。指示書AK 段階6 でも**残す**と決めた)★★ —
// ★`where` で顧客を絞ったので「複数顧客が混ざる」枝は通常もう発火しない★が、
// ★★同じ期間キーの重複は同一顧客でも起こりうる★★(`customer_summaries` に一意制約が無い。
// 期待値表 6-8)。★後勝ちで不定になるので、検知したら止める★。
// ★検知の中身(「全行の customer_id が1種類か」+ 期間キーの衝突)は
// `customerSummariesByPeriodKey()` が正★ — ここで手書きのループに戻さないこと。
const byPeriodKey = customerSummariesByPeriodKey(summaryRows);
// ★要求された期間の行だけを組み立てる★(前期の行は `KpiValue.prev` の材料であって、
// ★それ自体を返り値の行にしない★。fixtures 版も同じ)
// ★`periodOfSummaryRow()` には行(`prevKey`)をそのまま渡す★ —
// `month` / `week` を位置引数で渡すと取り違えても型エラーにならない(同関数の docstring)
const confirmedKpiRows: ReportKpiRow[] = summaryRows
.filter(
(row) =>
row.year === query.year &&
row.month === query.month &&
row.week === query.week
)
.map((row) => {
const period = periodOfSummaryRow(row);
const prevRow = byPeriodKey.get(
periodMapKey(
periodOfSummaryRow(prevKey),
prevKey.year,
prevKey.month,
prevKey.week
)
);
return {
period,
year: row.year,
month: row.month,
week: row.week,
kpi: kpiFromSummaryRatios(row, prevRow),
};
});
// ★★当期(進行中の週・月)でなければ詳細テーブルを1行も読まない★★ —
// 確定期間はサマリが正であり、★履歴から再計算しない(D-102 / `CLAUDE.md` 禁止事項1)★。
// ★`isCurrentPeriod()` は中で `todayJst()` を使う(当期判定の「正」はあちら1箇所)★
if (!range || !isCurrentPeriod(query.period, query.year, query.month, query.week)) {
return confirmedKpiRows;
}
// ★当期: clean_timetables × clean_task_reports × robot_error_reports を
// ★★`periodRange()` が返した範囲で SQL 側から絞って★★その場集計する
// (`cleanLogs()` が既に読んでいるものと同じテーブル・同じ絞り方)★。
// ★清掃結果の取得元は `clean_task_reports`★(指示書AQ 決定2/3。旧: `clean_status_histories`)。
// ★★年次の当期(今年)もここを通る★★(指示書AQ 決定1)— 範囲が ★1月1日〜12月31日★ になるので、
// ★本清掃行と `in` の塊の数が週次の約50倍になる★(`chunkForInClause()` で分割はされる)
// ★★`parseDate()` の「真の瞬間」を直接渡さないこと★★ — DB の DATETIME は
// 日本時間の壁時計値なので9時間ずれる(指示書AC C-6)。★変換は `dbDateRange()` の1箇所★。
const dbRange = dbDateRange(range.from, range.to);
/**
* ★★当期は「対象顧客の施設」だけを集計する★★(指示書AL ★段階1★)。
*
* ★確定期間は上の `customer_summaries` を `customer_id` で絞っている★のに対し、
* ★当期は詳細テーブルなので軸が**施設**になる★。★同じ顧客から解決した施設IDが
* 降りてくる契約★(`toReportKpiDbQuery()` / `app/api/reports/kpi/route.ts`)なので、
* ★2つの経路は同じ母集団を指す★。
*
* ★★`!== null` で分岐すること★★(★罠1 / 落とし穴3★)—
* ★`?.length` で分岐すると `[]`(0件ヒット)が「条件なし=全社合算」に化ける★。
* ★`in: undefined` も条件ごと消える★。★例外もテスト失敗も出ない★。
*/
const facilityIdWhere =
query.facility_ids !== null ? { in: query.facility_ids } : undefined;
// ★予定は2表から読む(指示書AU 段階3b。`findTimetables()`。★`where` は1回だけ書く★)★
const mainRows = await findTimetables(prisma, {
where: {
// ★本清掃だけを引く(メモリ側の filter をやめて SQL に降ろした)★
precheck: CLEAN_TIMETABLE_PRECHECK.main,
...(dbRange.gte || dbRange.lt ? { start_at: dbRange } : {}),
...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
},
// ★読む列は `CleanStatsTimetableLike`(`lib/db/report-mapping.ts`)が要求する4つだけ★
// (★`end_at` は結果待ちの判定用。D-173★)
select: { id: true, facility_id: true, start_at: true, end_at: true },
});
// ★引いた本清掃の id で関連行を引き直す★(★全件読みに戻さないこと★)。
// ★`in` の要素数の上限と分割は `chunkForInClause()` に閉じ込めてある★(指示書AC §E-5)。
// ★塊が0個(本清掃が0件)ならクエリを1本も撃たない★。
const mainIdChunks = chunkForInClause(mainRows.map((row) => row.id));
const [reportChunks, errorReportChunks] = await Promise.all([
Promise.all(
mainIdChunks.map((ids) =>
prisma.cleanTaskReport.findMany({ where: { clean_timetable_id: { in: ids } } })
)
),
Promise.all(
mainIdChunks.map((ids) =>
prisma.robotErrorReport.findMany({
where: { clean_timetable_id: { in: ids } },
// ★`robot_id` / `robot` を読まない(NULL の行で落ちないため。指示書AU 段階0-4)★
select: ROBOT_ERROR_COUNT_SELECT,
})
)
),
]);
// ★レポートを1件に絞る規則は `latestReportByTimetableId()` の1箇所★(指示書AQ §1-4)
const reportByTimetableId = latestReportByTimetableId(reportChunks.flat());
// ★エラー率の分子は★本清掃行を指す★robot_error_reports(TeamWorks 第8回 A7。D-114 項2)★ —
// 事前チェック行を指すものは含めない。`null` / dangling も入らない(決定3)。
// ★★上の `in` で既に「本清掃行を指すもの」だけを引いているが、判定の「正」は
// `errorCountByMainTimetable()` のまま★★(SQL 側はその写し。指示書AC C-5)
const mainStartAtById = mainTimetableStartAtById(mainRows);
const errCountByMainId = errorCountByMainTimetable(errorReportChunks.flat(), mainStartAtById);
// ★1回の集計で同じ now を使い回す(指示書V 落とし穴3。行ごとに作らない)★
// ★未実施の判定は `notExecutedFromReport()` を注入する★(`cleanLogs()` / `prechecks()` と
// 同じ1本。★ここにラムダで規則を書かないこと★ — 規則が2箇所に散る。D-103 項1)
const now = new Date();
const bucket = sumCleanStatsBuckets(
aggregateCleanStatsByFacility(
mainRows,
reportByTimetableId,
notExecutedFromReport,
errCountByMainId,
range,
now
).values()
);
// ★当期のprevは「1つ前の期間」=確定期間のサマリ行から取る★
// (当期の前は先週/先月/★前年★なのでサマリに存在するはず。無ければ prev=null になるだけ)
const prevRow = byPeriodKey.get(
periodMapKey(
periodOfSummaryRow(prevKey),
prevKey.year,
prevKey.month,
prevKey.week
)
);
const currentKpiRow: ReportKpiRow = {
period: query.period,
year: query.year,
month: query.month,
week: query.week,
kpi: kpiFromCleanStats(bucket, prevRow),
};
// ★確定期間の行があればそれが先(`selectKpiRow()` は最初の一致を採る)★ —
// 当期にサマリ行がある(先方のバッチが先走った)場合の優先順位を段階0 と揃える。
return [...confirmedKpiRows, currentKpiRow];
},
/**
* 施設別パフォーマンス。
*
* ★確定期間: `facility_summaries` をそのまま読む。履歴から再計算しない(D-102)★
* ★当期(今週・今月): 詳細テーブルからその場集計する★(`reportKpis()` と同じ方針。
* `CleanStatsBucket`(`lib/db/report-mapping.ts`)は元々「施設ごと」の集計を返すので、
* `reportKpis()` のように合算せずそのまま1施設1行として使う。指示書T 3-3の「共通化」)
*/
async facilityPerformance(query: FacilityPerformanceDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★★範囲を自分で計算しないこと★★(`reportKpis()` と同じ。月跨ぎ週の地雷。D-071)
const range = periodRange(query.period, query.year, query.month, query.week);
/**
* ★施設名の条件は「解決済みの施設ID配列」で降りてくる★(指示書AC C-1 / 段階5)。
* ★★`!== null` で分岐すること★★(★罠1★)— `in: undefined` は条件が消えて全件になる。
* ★`[]`(1件も当たらなかった)はそのまま `in: []` として渡し、0件を返させる★。
*/
const facilityIdWhere =
query.facility_ids !== null ? { in: query.facility_ids } : undefined;
// ---- 施設マスタ(facility_name / serial_number の結合元)----
// ★facility_summariesにserial_numberが無いのでrobotsを結合して引く(facilities()と同じ形)★
// ★★ロボットが0台の施設(fixturesの施設12)は行ごと除外する★★(推奨案。指示書T 3-1)—
// `cleanLogs()` / `schedules()` は例外を投げるが、レポートは「一覧の1行が欠ける」で
// 済むので例外にしない(一覧APIが1施設の欠落で丸ごと500になるのは重い)。
// ★★`deleted_at: null` を外さないこと★★ — 段階0 から付いている条件で、
// ★論理削除された施設はもともとこの一覧に出ない★(施設名で解決したIDが論理削除済みでも、
// ここで落ちて段階0 と同じ結果になる。★罠7 の扱いは `cleanLogs()` とは事情が違う★)。
const facilityRows = await prisma.facility.findMany({
where: {
deleted_at: null,
...(facilityIdWhere !== undefined ? { id: facilityIdWhere } : {}),
},
include: { robots: { where: { deleted_at: null } } },
});
const facilityById = new Map<
string,
{ facility_name: string; serial_number: string }
>();
for (const facility of facilityRows) {
const robot = facility.robots[0];
if (!robot) continue; // ロボット0台の施設は除外
facilityById.set(facility.id.toString(), {
facility_name: facility.facility_name,
serial_number: robot.serial_number,
});
}
// ---- 確定期間: facility_summaries をそのまま読む ----
// ★★「要求された期間 + その1つ前(前期比の材料)」の2期間ぶんだけ引く★★
// (指示書AC 段階5。★全件読みに戻さないこと★)。粒度は `week` が0かどうかで決まる
// (`CLAUDE.md` §4.7)ので、期間キー3つの一致で足りる。
const prevKey = shiftPeriod(query.period, query.year, query.month, query.week, -1);
const summaryRows = await prisma.facilitySummary.findMany({
where: {
...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
OR: [
{ year: query.year, month: query.month, week: query.week },
{ year: prevKey.year, month: prevKey.month, week: prevKey.week },
],
},
});
// ★★年次(month=0 && week=0)も読む★★(指示書AQ 決定1。2026-09-30 解禁)—
// ★以前はここで年次行を除外していた(「P1では読まない」D-072項4)★。
// ★`periodOfSummaryRow()` が `month` も見るようになった(指示書AQ 罠2)ので、
// 年次行が月次のキーで Map に入って月次の行を上書きすることはない★(`reportKpis()` と同じ理由)
const confirmedSummaryRows = summaryRows;
const byFacilityPeriodKey = new Map<string, (typeof confirmedSummaryRows)[number]>();
for (const row of confirmedSummaryRows) {
const period = periodOfSummaryRow(row);
byFacilityPeriodKey.set(
`${row.facility_id}/${periodMapKey(period, row.year, row.month, row.week)}`,
row
);
}
// ---- 連続問題日数(★着手前確認A案。D-103。分割キーはfacility_id。指示書V 不具合B★)----
// `computeConsecutiveProblemDays()`(`cleanLogs()` と同じ関数・同じ入力の作り方)で
// 施設単位・同一日OR畳み込み済みの連続日数を出し、`consecutiveProblemDaysAtPeriodEnd()` で
// 「期間内でその施設の最終清掃ログ」の値を1施設1期間ぶん選ぶ。
// ★`ConsecutiveInput`は`facility_id`を持つのでFacilityConsecutiveEntryの代わりに使える★
// (以前あった`serial_number: String(facility_id)`という詰め替えは不要になった)
// ★★入力の作り方は `loadConsecutiveInputs()` の1本に集約してある★★(指示書AC 段階1)—
// `cleanLogs()` / `continuousProblems()` と同じ1本。★ここに書き下さないこと★(D-109 の再発防止)。
// ★`now` は下の `aggregateCleanStatsByFacility()` とも共有する(指示書V 落とし穴3)★
// ★★`loadConsecutiveInputs()` には「★要求された期間そのもの★」を渡すこと★★
// (★★指示書AC C-2 の地雷★★)— 連続日数は期間の手前まで遡って数えるので、
// ★期間で切り落とすと前日のデータが消えて連続が途切れ、L3 が L1 に化ける
// (例外もテスト失敗も出ない)★。★遡りぶん(保存された表示上限 +1日)は同関数が足す★
// (段階6。★ここで足さないこと★ — 3経路で遡り幅が食い違う。D-109)。
// ★`range` が `null`(その月に存在しない週)なら下限なし=全期間のまま★
const now = new Date();
const consecutiveEntries: ConsecutiveInput[] = await loadConsecutiveInputs(
prisma,
now,
{ date_from: range?.from ?? null, date_to: range?.to ?? null }
);
const consecutiveById = computeConsecutiveProblemDays(consecutiveEntries);
/**
* 施設マスタ・判定・連続日数の組み立てを確定期間/当期で共有する。
* ★引数名を `range` にしないこと★ — 外側の `range`(要求された期間)を隠してしまう。
*/
const buildItem = (
facilityId: number,
kpi: ReportKpi,
itemRange: { from: string; to: string }
): FacilityPerformance | null => {
const facility = facilityById.get(String(facilityId));
if (!facility) return null; // ロボット0台の施設は除外
return {
facility_id: facilityId,
facility_name: facility.facility_name,
serial_number: facility.serial_number,
operation_rate: kpi.operation_rate,
launch_rate: kpi.launch_rate,
completion_rate: kpi.completion_rate,
error_rate: kpi.error_rate,
// ★status は judgeFacilityStatus() 経由で決める。直書きしない(CLAUDE.md §7)★
status: judgeFacilityStatus(kpi.operation_rate.value),
// ★consecutive_problem_days はA案。期間内に清掃ログが無ければ0★(着手前確認。D-103)
consecutive_problem_days: consecutiveProblemDaysAtPeriodEnd(
consecutiveEntries,
consecutiveById,
facilityId,
itemRange
),
};
};
/** 前期のサマリ行(前期比の材料)を施設ごとに引く。★確定期間と当期で同じキーを使う★ */
const prevRowOf = (facilityId: bigint | number) =>
byFacilityPeriodKey.get(
`${facilityId}/${periodMapKey(
periodOfSummaryRow(prevKey),
prevKey.year,
prevKey.month,
prevKey.week
)}`
);
// ---- 確定期間の行を組み立てる ----
// ★引いた2期間のうち「要求された期間」の行だけを行にする★
// (前期の行は `prevRowOf()` の材料であって、それ自体は返さない)
const confirmedRows: FacilityPerformanceRow[] = [];
for (const row of confirmedSummaryRows) {
if (
row.year !== query.year ||
row.month !== query.month ||
row.week !== query.week
) {
continue;
}
const period = periodOfSummaryRow(row);
if (!range) {
// ★サマリ行が持つ期間キーは既に妥当なはず(DB由来。API入力の検証とは別)★
// ★行があるのに `periodRange()` が解けない=先方のサマリかこちらの週の数え方が
// 食い違っている★ので fail-loud のまま残す(行が無ければここには来ない)。
// ★★年次の解禁(指示書AQ 決定1)でも条件は緩めない★★ — `periodRange("yearly", …)` は
// ★整数の年なら必ず範囲を返す★(担当A。`lib/format/period.ts`)ので、年次の行はここに来ない。
// ★週次・月次で null が返るのは本当に異常★(存在しない週の行がサマリにある等)。
throw new Error(
`[db] facilityPerformance: facility_summaries(id=${row.id})の期間範囲を算出できませんでした`
);
}
const item = buildItem(
Number(row.facility_id),
kpiFromSummaryRatios(row, prevRowOf(row.facility_id)),
range
);
if (!item) continue;
confirmedRows.push({
period,
year: row.year,
month: row.month,
week: row.week,
item,
});
}
// ★★当期(進行中の週・月)でなければ詳細テーブルを1行も読まない★★ —
// 確定期間はサマリが正であり、★履歴から再計算しない(D-102 / `CLAUDE.md` 禁止事項1)★
if (!range || !isCurrentPeriod(query.period, query.year, query.month, query.week)) {
return confirmedRows;
}
// ---- 当期: 詳細テーブルを ★`periodRange()` の範囲で絞って★ その場集計する ----
// ★変換は `dbDateRange()`(`toDbJst()` を通す)の1箇所★(指示書AC C-6)。
const dbRange = dbDateRange(range.from, range.to);
// ★予定は2表から読む(指示書AU 段階3b。`findTimetables()`。★`where` は1回だけ書く★)★
const mainRows = await findTimetables(prisma, {
where: {
// ★本清掃だけを引く(メモリ側の filter をやめて SQL に降ろした)★
precheck: CLEAN_TIMETABLE_PRECHECK.main,
...(dbRange.gte || dbRange.lt ? { start_at: dbRange } : {}),
// ★★`!== null` の分岐は `facilityIdWhere` に閉じ込めてある★★(罠1)
...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
},
// ★読む列は `CleanStatsTimetableLike` が要求する4つだけ★(★`end_at` は結果待ちの判定用。D-173★)
select: { id: true, facility_id: true, start_at: true, end_at: true },
});
// ★引いた本清掃の id で関連行を引き直す★(`reportKpis()` と同じ作法。★全件読みに戻さない★)
const mainIdChunks = chunkForInClause(mainRows.map((row) => row.id));
// ★清掃結果の取得元は `clean_task_reports`★(指示書AQ 決定2/3。`reportKpis()` と同じ)。
// ★★年次の当期(今年)もここを通る★★(指示書AQ 決定1。範囲が1年ぶんになる)
const [reportChunks, errorReportChunks] = await Promise.all([
Promise.all(
mainIdChunks.map((ids) =>
prisma.cleanTaskReport.findMany({ where: { clean_timetable_id: { in: ids } } })
)
),
Promise.all(
mainIdChunks.map((ids) =>
prisma.robotErrorReport.findMany({
where: { clean_timetable_id: { in: ids } },
// ★`robot_id` / `robot` を読まない(NULL の行で落ちないため。指示書AU 段階0-4)★
select: ROBOT_ERROR_COUNT_SELECT,
})
)
),
]);
// ★レポートを1件に絞る規則は `latestReportByTimetableId()` の1箇所★(指示書AQ §1-4)
const reportByTimetableId = latestReportByTimetableId(reportChunks.flat());
// ★エラー率の分子は★本清掃行を指す★robot_error_reports(TeamWorks 第8回 A7。D-114 項2)★ —
// 事前チェック行を指すものは含めない。`null` / dangling も入らない(決定3)。
// ★判定の「正」は `errorCountByMainTimetable()` のまま(SQL 側はその写し。C-5)★
const mainStartAtById = mainTimetableStartAtById(mainRows);
const errCountByMainId = errorCountByMainTimetable(
errorReportChunks.flat(),
mainStartAtById
);
// ★段階2の`CleanStatsBucket`は元々「施設ごと」のMapを返す。ここでは合算せず、
// 施設ごとにそのまま1行へ変換する(指示書T 3-3の「共通化」)★
// ★now は上(連続日数の算出)で作った同じインスタンスを使い回す(指示書V 落とし穴3)★
// ★未実施の判定は `notExecutedFromReport()` を注入する(`reportKpis()` と同じ1本)★
const buckets = aggregateCleanStatsByFacility(
mainRows,
reportByTimetableId,
notExecutedFromReport,
errCountByMainId,
range,
now
);
const currentRows: FacilityPerformanceRow[] = [];
for (const [facilityId, bucket] of buckets) {
const item = buildItem(
facilityId,
kpiFromCleanStats(bucket, prevRowOf(facilityId)),
range
);
if (!item) continue;
currentRows.push({
period: query.period,
year: query.year,
month: query.month,
week: query.week,
item,
});
}
return [...confirmedRows, ...currentRows];
},
/**
* 連続問題施設(監視側の最後の未実装。指示書W)。
*
* ★対応するサマリテーブルが無い(D-102項4)ため、確定期間(先週・先月)も当期
* (今週・今月)もどちらも詳細テーブルから算出する(決定2)。★`todayJst()` /
* `isCurrentPeriod()` を使った当期の場合分けは書かない★ — ★どちらも同じ1本の処理★。
*
* ★★2026-09-13(指示書AC 段階5): 期間キーを「データの日付から列挙する」のをやめた★★ —
* ★引数で期間が決まるので、その1期間だけを組み立てる★(以前は全期間ぶんの行を作り、
* ルート側の `selectContinuousProblemRows()` が1期間を選んでいた)。
* ★`reportPeriodKeysFromDates()`(`lib/db/report-mapping.ts`)はこの経路では
* 呼ばなくなった★(純粋関数とテストは残してある。理由はあちらの docstring)。
*/
async continuousProblems(query: ReportDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★★範囲を自分で計算しないこと★★(`reportKpis()` と同じ。月跨ぎ週の地雷。D-071)—
// ★`null` = その月に存在しない週★(5週しかない月の第6週など)。
// ★★段階0 もその期間の行を1件も作っていない(データの日付から列挙していたため)★★ので、
// ★例外にせず空配列を返す★(ルート側は `items: []` を返す。404 にしない)。
const range = periodRange(query.period, query.year, query.month, query.week);
if (!range) return [];
// ★robots を join しない(ContinuousProblem に serial_number は無い。決定5)★
// ロボット0台の施設を除外すると、機体を撤去・交換中の施設のエスカレーションが
// 画面から消える(fail-silent)。facilityPerformance() の buildItem() とは事情が違う。
// ★★分類の絞り込みはここにかける★★(指示書AK 段階4b)—
// ★`continuousProblemsInPeriod()` は `facilityNameById` を回して行を作る★ので、
// ★ここで母集団が決まる★(`facilityPerformance()` が `facilityById` で決めているのと同じ形)。
// ★★`!== null` で分岐すること★★(★罠1★。`in: undefined` は条件が消えて全件になる)。
// ★`[]`(1件も当たらなかった)はそのまま `in: []` として渡し、0件を返させる★。
const facilityIdWhere =
query.facility_ids !== null ? { in: query.facility_ids } : undefined;
const facilityRows = await prisma.facility.findMany({
where: {
deleted_at: null,
...(facilityIdWhere !== undefined ? { id: facilityIdWhere } : {}),
},
});
const facilityNameById = new Map<number, string>(
facilityRows.map((f) => [Number(f.id), f.facility_name])
);
// ★★`ConsecutiveInput` の作り方は `loadConsecutiveInputs()` の1本に集約してある★★
// (`cleanLogs()` / `facilityPerformance()` と同じ1本。★2画面で連続日数が食い違わない
// ようにするため、ここに書き下さないこと★。D-103 / D-109 の再発防止。指示書AC 段階1)。
// ★`now` は1回だけ作って使い回す(行ごとに作ると境界の行の扱いがぶれる)★
// ★★`isFutureSchedule()` を外さないこと — 外すと未来7日ぶんが全部「出発失敗」になり
// 全施設が常時L3になる(D-106 / D-107 / D-110)★★(除外は同関数の中で行っている)
// ★★段階6: 「★要求された期間そのもの★」を渡す★★(★遡りぶんは同関数が足す★)。
// ★`range` はこの上で `null` を弾いてあるので、ここでは必ず期間が決まっている★
const now = new Date();
const entries: ConsecutiveInput[] = await loadConsecutiveInputs(prisma, now, {
date_from: range.from,
date_to: range.to,
});
const consecutiveById = computeConsecutiveProblemDays(entries);
// ★確定期間も当期も詳細から算出する(対応するサマリが無い。D-102項4 / 決定2)★
// ★要求された1期間ぶんだけ組み立てる(指示書AC 段階5)★
const key: ReportPeriodKey = {
period: query.period,
year: query.year,
month: query.month,
week: query.week,
};
return continuousProblemsInPeriod(
entries,
consecutiveById,
facilityNameById,
range
).map((item): ContinuousProblemRow => ({ ...key, item }));
},
/**
* エラー種別内訳(全社の発生件数・rate)。
*
* ★facility版で確定(D-102項4の解決)★ — `customer_error_summaries` は読まない。
* `facility_error_summaries`(`facilityPerformance()` と同じテーブル系統)を読み、
* 同一期間・同一 `error_type` を全施設ぶん足し上げる(`errorBreakdownRowsFromSummary()`)。
* ★確定期間: サマリの `error_count` をそのまま合算する。履歴から再計算しない(D-102)★
* ★当期(今週・今月): ★★本清掃行を指す★★ `robot_error_reports` を期間で絞って
* `error_type` ごとに数える★(`countMainErrorTypesInRange()`)—
* ★事前チェック行を指すもの / `clean_timetable_id` が `null` のもの / dangling は数えない★
* (TeamWorks 第8回 A7。2026-09-11。D-114 項2。決定2・決定3)。
* ★★率(`reportKpis()` / `facilityPerformance()`)と分子の集合を同じにするため★★ —
* 同じレポート画面に定義の違う2つの数字を並べない。
* ★★期間判定は「そのエラーが指す本清掃行の `start_at` の JST 日付」★★(決定7)—
* ★以前は `detectedAtFromError()`(`errored_at ?? created_at`)だった★。
* エラー率の分母が清掃予定回数(P-6=B)なので、分子も予定(本清掃)を基準にする。
* ★`type` / `label` は生値のまま。7分類・日本語への変換はしない(D-077/D-088)★
*/
async errorBreakdown(query: ReportDbQuery) {
const { getPrisma } = await import("./prisma");
const prisma = await getPrisma();
// ★★範囲を自分で計算しないこと★★(`reportKpis()` と同じ。月跨ぎ週の地雷。D-071)
const range = periodRange(query.period, query.year, query.month, query.week);
// ★★施設の条件(分類)を `where` に降ろす★★(指示書AK ★段階4b / §7-3★)—
// ★D-133 の申し送り③「`facility_error_summaries` を `facility_id` で絞っていない」を
// ここで解消している★。★★`!== null` で分岐すること★★(★罠1★。
// `in: undefined` は条件が消えて全件になる)。★`[]` はそのまま `in: []` で0件★。
// ★`rate`(全エラー件数に対する割合)の分母も絞ったあとの母集団で計算される★ —
// ★それが正しい★(「この屋号のエラーのうち何%」が見たい)。
const facilityIdWhere =
query.facility_ids !== null ? { in: query.facility_ids } : undefined;
// ---- 確定期間: facility_error_summaries を読み、施設ぶん足し上げる ----
// ★★要求された期間の行だけを引く★★(指示書AC 段階5。★全件読みに戻さないこと★)。
// ★★前期の行は要らない★★ — エラー種別内訳に前期比は無い(`ErrorBreakdownItem` に
// `prev` が無い)ので、`reportKpis()` / `facilityPerformance()` のような2期間読みはしない。
const summaryRows = await prisma.facilityErrorSummary.findMany({
where: {
year: query.year,
month: query.month,
week: query.week,
...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
},
});
// ★★年次(month=0 && week=0)も読む★★(指示書AQ 決定1。2026-09-30 解禁)—
// ★以前はここで年次行を除外していた(「P1では読まない」D-072項4)★。
// ★`errorBreakdownRowsFromSummary()` は中で `periodOfSummaryRow()` を使い、
// それが `month` も見るようになった(指示書AQ 罠2)ので、★年次の件数が月次に
// 足し込まれることはない★(`reportKpis()` と同じ理由)
const confirmedGrouped = errorBreakdownRowsFromSummary(summaryRows);
const confirmedRows: ErrorBreakdownRow[] = confirmedGrouped.flatMap((group) =>
group.items.map((item) => ({
period: group.period,
year: group.year,
month: group.month,
week: group.week,
item,
}))
);
// ★★当期(進行中の週・月)でなければ詳細テーブルを1行も読まない★★ —
// 確定期間はサマリが正であり、★履歴から再計算しない(D-102 / `CLAUDE.md` 禁止事項1)★
if (!range || !isCurrentPeriod(query.period, query.year, query.month, query.week)) {
return confirmedRows;
}
// ---- 当期: ★本清掃行を指す★ robot_error_reports を期間で絞って error_type ごとに数える ----
// ★期間帰属は「そのエラーが指す本清掃行の `start_at` の JST 日付」★(決定7)なので
// `clean_timetables` も読む。★★その本清掃行を先に期間で絞れば、エラー行は
// `clean_timetable_id` で引き直せる★★(全件読みに戻さない。指示書AC 段階5)。
// ★変換は `dbDateRange()`(`toDbJst()` を通す)の1箇所★(指示書AC C-6)。
const dbRange = dbDateRange(range.from, range.to);
// ★予定は2表から読む(指示書AU 段階3b。`findTimetables()`。★`where` は1回だけ書く★)★
const mainRows = await findTimetables(prisma, {
where: {
// ★本清掃だけを引く(メモリ側の filter をやめて SQL に降ろした)★
precheck: CLEAN_TIMETABLE_PRECHECK.main,
...(dbRange.gte || dbRange.lt ? { start_at: dbRange } : {}),
// ★★分類の絞り込み(指示書AK 段階4b)★★ — ★`facilityPerformance()` の当期と同じ形★。
// ★★`!== null` の分岐は `facilityIdWhere` に閉じ込めてある★★(罠1)
...(facilityIdWhere !== undefined ? { facility_id: facilityIdWhere } : {}),
},
// ★読む列は `MainTimetableLike` が要求する3つだけ★
select: { id: true, facility_id: true, start_at: true },
});
const mainStartAtById = mainTimetableStartAtById(mainRows);
const errorReportChunks = await Promise.all(
chunkForInClause(mainRows.map((row) => row.id)).map((ids) =>
prisma.robotErrorReport.findMany({
where: { clean_timetable_id: { in: ids } },
// ★`robot_id` / `robot` を読まない(NULL の行で落ちないため。指示書AU 段階0-4)★
select: ROBOT_ERROR_TYPE_SELECT,
})
)
);
// ★★分子は「本清掃行を指すエラー」だけ★★(D-118 / D-114 項2。★事前チェック行を数えない★)—
// ★SQL 側の `in` で既にそうなっているが、判定の「正」は
// `countMainErrorTypesInRange()` のまま★(SQL 側はその写し。指示書AC C-5)
const currentItems = errorBreakdownItemsFromCounts(
countMainErrorTypesInRange(errorReportChunks.flat(), mainStartAtById, range)
);
const currentRows: ErrorBreakdownRow[] = currentItems.map((item) => ({
period: query.period,
year: query.year,
month: query.month,
week: query.week,
item,
}));
return [...confirmedRows, ...currentRows];
},
};
export const db: DataSource = USE_FIXTURES ? fixtureSource : prismaSource;