/**
* 予定(清掃タイムテーブル)を ★`clean_timetables` と `clean_timetable_histories` の両方から★ 読む共通の読み口
* (指示書AU 段階3b。TeamWorks 第11回 P-7=C。D-158 項4)。
*
* ------------------------------------------------------------------------
* ★★規則(ここ1箇所にしか書かない)★★
* ------------------------------------------------------------------------
* 1. ★同じ `args` を2表の `findMany` に渡す★(`where` を2回書かない。片方だけ直すと静かにずれる)
* 2. ★同じ `id` は1件として扱い、`clean_timetables`(本表)側を採る★
* — ★履歴の `id` は本表の `id` のコピー★(ユーザー回答 2026-10-05。§3b-7 Q1)なので id で重複を除けば足りる
* 3. ★「本表が勝つ」を厳密に守るための影チェック★: 履歴だけに出た id を本表で引き直し、
* ★本表に行があればその履歴の行は捨てる★。
* 理由: 同じ id で `start_at` や `precheck` が2表で違うと、本表の行は `where` から外れて履歴の行だけが当たる。
* 影チェックが無いと ★負けるはずの履歴の行が出る★(T5)。★履歴だけの id が0件ならクエリを撃たない★(T6)
* 4. ★同じ id で `facility_id` / `section_id` / `start_at` のどれかが違ったら `db.timetable_conflict` の warn★
* (§3b-7 Q2。採るのは本表のまま)。比べるのは ★両方の行に載っている列だけ★(`select: { id }` なら比べない)
* 5. ★期間の基準は `start_at` のまま★(Q3。`CLAUDE.md` §4.7)。★`inactive` で絞らない★(履歴に無い列。
* `where` に書くと型エラーになる — それでよい)
*
* ★実績は `clean_task_reports` だけのまま★(D-151)。この読み口は予定の表だけを扱う。
*
* ------------------------------------------------------------------------
* ★★やってはいけないこと★★
* ------------------------------------------------------------------------
* - ★`count` / `groupBy` を2表にまたがって書かない★(同じ id の重複を引けない)。
* 要るなら `findTimetables()` で id を取ってから数える(2026-10-05 時点で該当0件)
* - ★`take` / `skip` / `cursor` / `distinct` を渡さない★(2表を合わせた後の件数・順序と食い違う。型で禁止してある)
* - ★`orderBy` は渡してよいが、合わせた後の順序は保証されない★(呼び出し側が並べ直すこと)
*
* ------------------------------------------------------------------------
* ★★性能(3b-4)★★
* ------------------------------------------------------------------------
* 往復は1本 → 2本(+履歴だけの id があれば影チェック)。2表は `Promise.all` で並べる。
* 両表とも `start_at` / `facility_id` / `parent_id` に索引がある(`prisma/schema.prisma`)。
* ★古い予定が履歴へ移るなら行の大半(保持2年)は履歴側★で、★期間無指定の経路は履歴も全期間読む★
* (`cleanLogs()` の日付なし・`CONSECUTIVE_RANGE_UNBOUNDED`・総清掃面積の期間なし)。
* 今と同じ性質なので新たに絞らないが、★`judge.*` ログに出る行数は履歴のぶん増える★。
* `IN` の分割は既存の `chunkForInClause()`(上限 `DB_IN_CLAUSE_MAX_ITEMS`)。
*
* ★生SQL は使わない★(`CLAUDE.md` §3)。すべて Prisma のクエリビルダ。
*/
import { chunkForInClause } from "@/lib/db/chunked";
import { jstIsoFromDb } from "@/lib/format/date";
import type { Prisma } from "@/lib/generated/prisma/client";
import { logEvent } from "@/lib/logger";
import type { DbTimetableConflictLogEvent, TimetableConflictColumn } from "@/lib/logger/events";
/**
* 2表に渡す `findMany` の引数。
*
* ★型は履歴側(`CleanTimetableHistoryFindManyArgs`)で取る★ — 履歴の列は本表の列の部分集合なので、
* ★履歴に通る引数は本表にも通る★(逆は `inactive` / `updated_at` で通らない=書けば型エラーになる)。
* ★`select` を書くなら `id: true` を必ず含む★(重複を id で除くため。型で強制する)。
* `select` なし(`include` のみ/全列)なら id は必ず載る。
* ★`take` / `skip` / `cursor` / `distinct` / `omit` は渡せない★(ファイル冒頭)。
*/
export type TimetableFindManyArgs = Omit<
Prisma.CleanTimetableHistoryFindManyArgs,
"select" | "take" | "skip" | "cursor" | "distinct" | "omit"
> & {
// ★`Omit` だけでは総称の引数で余分なキーが通ってしまうので、`never` で明示的に塞ぐ★
take?: never;
skip?: never;
cursor?: never;
distinct?: never;
omit?: never;
} & (
| { select: Prisma.CleanTimetableHistorySelect & { id: true }; include?: undefined }
| { select?: undefined }
);
/**
* `findTimetables(prisma, args)` が返す1行の型(★履歴側のモデルで決める★。本表だけの列は載らない)。
* `& { id: bigint }` は「`id` が必ず載る」(`TimetableFindManyArgs` で強制済み)を総称のまま型に示すため。
*/
export type TimetableRow<A extends TimetableFindManyArgs> = Prisma.CleanTimetableHistoryGetPayload<A> & {
id: bigint;
};
/**
* この読み口が必要とする Prisma の最小限の形(★テストからスタブを渡せるようにするため★)。
*
* ★本表の `findMany` は影チェック(`{ where: { id: { in } }, select }`)も受ける★。
* 戻りは `unknown`(型は引数から `TimetableRow<A>` で決める。生成済みクライアントの総称メソッドを
* そのまま受けられるよう、ここでは広く取る)。
*/
export interface TimetableSourcePrisma {
cleanTimetable: { findMany(args: object): Promise<readonly unknown[]> };
cleanTimetableHistory: { findMany(args: object): Promise<readonly unknown[]> };
}
/** `findTimetableById()` が必要とする Prisma の最小限の形 */
export interface TimetableByIdPrisma {
cleanTimetable: { findUnique(args: object): Promise<unknown> };
cleanTimetableHistory: { findUnique(args: object): Promise<unknown> };
}
/** 2表で中身を比べる列(§3b-7 Q1/Q2)。★並び順が warn の `columns` の順になる★ */
const CONFLICT_COLUMNS: readonly TimetableConflictColumn[] = ["facility_id", "section_id", "start_at"];
/** 影チェックで本表から読む列(★比べる列も読む★ — 本表が期間外のときも Q2 の warn を出すため) */
const SHADOW_SELECT = { id: true, facility_id: true, section_id: true, start_at: true } as const;
interface ShadowRow {
id: bigint;
facility_id: bigint;
section_id: bigint;
start_at: Date;
}
/**
* 履歴の行を id で引けるようにする。★同じ id が2回出たら例外★(履歴の主キーは `id` なので起きない。
* 起きたら呼び出しの誤り — 例えば同じ id が2つの塊に入る `where` を書いた)。
*/
function indexHistoryRows<T extends { id: bigint }>(historyRows: readonly T[]): Map<string, T> {
const byId = new Map<string, T>();
for (const row of historyRows) {
const key = row.id.toString();
if (byId.has(key)) {
throw new Error(
`[db] timetable-source: clean_timetable_histories の同じ id=${key} が2回返りました(呼び出しの誤り)`
);
}
byId.set(key, row);
}
return byId;
}
/**
* ★純粋関数★ — 本表の行と履歴の行を合わせる(§3b-2 の 1)。
*
* - `mainRows` を全部採る
* - `historyRows` は ★`id.toString()` が `mainRows` にも `shadowedIds` にも無いものだけ★ 足す
* - ★`historyRows` の中で同じ id が2回出たら例外★
* - ★並び順は保証しない★(本表の行 → 履歴だけの行の順に並ぶが、呼び出し側はこれに頼らないこと)
*
* @param shadowedIds 影チェックで本表に行があった id(★`where` から外れた本表の行が勝つ★)
*/
export function mergeTimetableRows<T extends { id: bigint }>(
mainRows: readonly T[],
historyRows: readonly T[],
shadowedIds: ReadonlySet<string>
): T[] {
const historyById = indexHistoryRows(historyRows);
const mainIds = new Set(mainRows.map((row) => row.id.toString()));
const merged = [...mainRows];
for (const [key, row] of historyById) {
if (mainIds.has(key) || shadowedIds.has(key)) continue;
merged.push(row);
}
return merged;
}
/** 列の値を warn に出す文字列にする(id は文字列、日時は `jstIsoFromDb()`) */
function conflictValue(value: unknown): string {
return value instanceof Date ? jstIsoFromDb(value) : String(value);
}
function sameValue(a: unknown, b: unknown): boolean {
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
return String(a) === String(b);
}
/**
* 同じ id の本表の行と履歴の行を比べ、違えば `db.timetable_conflict` の warn を出す(§3b-7 Q2)。
* ★両方の行に載っている列だけ★比べる。★採るのは本表★(この関数は出すだけで、行は選ばない)。
*/
function reportConflict(main: Record<string, unknown>, history: Record<string, unknown>): void {
const columns = CONFLICT_COLUMNS.filter(
(col) => col in main && col in history && !sameValue(main[col], history[col])
);
if (columns.length === 0) return;
const event: DbTimetableConflictLogEvent = {
event: "db.timetable_conflict",
timetable_id: String(main.id),
adopted_table: "clean_timetables",
columns,
};
for (const col of columns) {
event[`main_${col}`] = conflictValue(main[col]);
event[`history_${col}`] = conflictValue(history[col]);
}
logEvent("warn", event);
}
/**
* 2表から引いた行を合わせる(影チェック・Q2 の warn・重複除去)。
* `findTimetables()` と `findTimetablesByChunks()` が共有する(★規則を1箇所にする★)。
*/
async function resolveTimetableRows<T extends { id: bigint }>(
prisma: TimetableSourcePrisma,
mainRows: readonly T[],
historyRows: readonly T[]
): Promise<T[]> {
const historyById = indexHistoryRows(historyRows);
const mainById = new Map(mainRows.map((row) => [row.id.toString(), row]));
// ★両方に当たった id は中身を比べる(Q2)★
for (const [key, history] of historyById) {
const main = mainById.get(key);
if (main) reportConflict(main, history);
}
// ★影チェック★ — 履歴だけに出た id を本表で引き直す。★0件ならクエリを撃たない★
const historyOnly = [...historyById.values()].filter((row) => !mainById.has(row.id.toString()));
const shadowChunks = await Promise.all(
chunkForInClause(historyOnly.map((row) => row.id)).map(
async (ids) =>
(await prisma.cleanTimetable.findMany({
where: { id: { in: ids } },
select: SHADOW_SELECT,
})) as ShadowRow[]
)
);
const shadowedIds = new Set<string>();
for (const shadow of shadowChunks.flat()) {
const key = shadow.id.toString();
shadowedIds.add(key);
const history = historyById.get(key);
if (history) reportConflict({ ...shadow }, history);
}
return mergeTimetableRows(mainRows, historyRows, shadowedIds);
}
/**
* 予定を2表から読む(§3b-2 の 2)。★同じ `args` を `cleanTimetable.findMany` と
* `cleanTimetableHistory.findMany` に `Promise.all` で渡す★。
*
* @param args ★`where` の中身は今までどおり★(`start_at` で期間・`precheck` で本清掃)。`inactive` は書かない
* @returns 合わせた行。★並び順は保証しない★(`orderBy` を渡しても2表を合わせた後は写しにもならない)
*/
export async function findTimetables<A extends TimetableFindManyArgs>(
prisma: TimetableSourcePrisma,
args: A
): Promise<TimetableRow<A>[]> {
const [mainRows, historyRows] = await Promise.all([
prisma.cleanTimetable.findMany(args),
prisma.cleanTimetableHistory.findMany(args),
]);
return resolveTimetableRows(
prisma,
mainRows as TimetableRow<A>[],
historyRows as TimetableRow<A>[]
);
}
/**
* `IN` を塊に分けて引く経路用(§3b-3。`parent_id IN` / `id IN`)。
*
* ★全塊を2表とも引いてから、1回だけ `mergeTimetableRows()` を通す★ —
* 2表で `parent_id` が違う同じ id は別の塊に入りうる(T8)ので、塊の中の重複落としだけでは足りない。
* ★塊ごとに `buildArgs()` を1回だけ呼び、その同じ引数を2表に渡す★。★塊が0個ならクエリを撃たない★。
*/
export async function findTimetablesByChunks<C, A extends TimetableFindManyArgs>(
prisma: TimetableSourcePrisma,
chunks: readonly C[],
buildArgs: (chunk: C) => A
): Promise<TimetableRow<A>[]> {
if (chunks.length === 0) return [];
const results = await Promise.all(
chunks.map((chunk) => {
const args = buildArgs(chunk);
return Promise.all([
prisma.cleanTimetable.findMany(args),
prisma.cleanTimetableHistory.findMany(args),
]);
})
);
return resolveTimetableRows(
prisma,
results.flatMap(([main]) => main) as TimetableRow<A>[],
results.flatMap(([, history]) => history) as TimetableRow<A>[]
);
}
/**
* 予定を1件、id で引く(`precheckItemById()` 用。§3b-2 の 5)。
*
* ★本表を `findUnique` → 無ければ履歴を `findUnique`★(本表が勝つ)。同じ `args` を両方に渡す。
* ★本表で見つかったら履歴は引かない★(往復は本表にあるとき1本・無いとき2本)。
*
* @param select ★`id: true` を必ず含む★(`findTimetables()` と同じ作法)
*/
export async function findTimetableById<S extends Prisma.CleanTimetableHistorySelect & { id: true }>(
prisma: TimetableByIdPrisma,
id: bigint,
select: S
): Promise<Prisma.CleanTimetableHistoryGetPayload<{ select: S }> | null> {
const args = { where: { id }, select };
const main = await prisma.cleanTimetable.findUnique(args);
if (main) return main as Prisma.CleanTimetableHistoryGetPayload<{ select: S }>;
const history = await prisma.cleanTimetableHistory.findUnique(args);
return (history ?? null) as Prisma.CleanTimetableHistoryGetPayload<{ select: S }> | null;
}