import type { AreaCompletionRow, CleanLogItem } from "@/lib/types";
import { cleanLogs } from "./clean-logs";
import { dateOnly } from "./_helpers";
/**
* エリア別完了率(GET /clean-logs/{id}/areas)のサンプルデータ
*
* docs/fixtures.md §2.5 のケースを網羅しています。
*
* ★★1エリア = 1清掃ロボット★★(`CLAUDE.md` §8 / TeamWorks 第8回 A9-2)。
* ★1回の清掃の中を売り場・通路・レジ前に区切った内訳ではありません★(その粒度は取得不可)。
*
* ★★日付と id は `cleanLogs` の**既存の行から引いています**★★ —
* 手書きの日付を新たに作ると、`lib/fixtures/clean-logs.ts` が実行日相対で日付を作っている
* ため、日をまたいだ瞬間に「同じ施設・同じ日」の突き合わせが外れます。
* ★`lib/fixtures/` の既存ファイルは1行も変更していません(追加のみ)★。
*
* ★行の `completion_rate` は対応する `CleanLogItem.completion_rate` と同じ値にしてあります★ —
* 一覧(清掃詳細ログ)の行と、そこから開くモーダルの行で数字が食い違わないようにするため
* (指示書Z 決定6 と同じ理由)。
* ★★行の完了率は面積から `runCompletionRate()` で決まる★★(D-155)— ★面積は `completion_rate` から作り、
* `runCompletionRate()` で元の完了率に戻ることをテストで確かめている★(`__tests__/areas.test.ts`)。
* ★★全体率(`overall_completion_rate`)は行の完了率の単純平均★★
* (清掃1回=1票。未実施の行は分母に入れない。`lib/judge/kpi.ts` の `meanCleanRatio()`)。
* ★指示書AQ §5-5 で面積ベース(Σclean_area ÷ Σtask_area)から変わった★(旧: 指示書Z 決定7)。
* ★期間の合計どうし(Σclean_area ÷ Σtask_area)は全体率の分子・分母ではない★。
*/
/**
* ★シリアル → `section_id`★(末尾4桁を数値にする)
*
* ローカルDBのシードは ★`section_id` をロボットの id と同じにしている★
* (`scripts/seed-local-db.ts`。1清掃エリア=1ロボット)。fixtures でも
* 「エリアIDはロボットに対応する」という関係が見えるように、シリアルから決定的に作る。
*/
function sectionIdOf(serialNumber: string): number {
return Number(serialNumber.slice(-4));
}
/**
* ★`section_name` はシードと同じ形★(`清掃エリア(ロボット107)`)。
* ★モックの「1F 生鮮エリア」のような名前は作らない★ — 実装では
* `sections.section_name` の生値をそのまま出すため、fixtures も同じ流儀に揃える
* (実在の店舗名・エリア名を入れないこと。docs/fixtures.md §1)。
*/
function sectionNameOf(sectionId: number): string {
return `清掃エリア(ロボット${sectionId})`;
}
interface AreaGroupSpec {
facility_id: number;
/** `cleanLogs` と同じ「n日前」。日付は `dateOnly()` で引く */
daysBack: number;
/** 何を確認するためのケースか(docs/fixtures.md §2.5 と対応させる) */
note: string;
/**
* シリアル → `task_area`(清掃対象面積)。
* ★キーの集合が「その施設・その日のエリアの集合」そのもの★なので、
* `cleanLogs` 側の行と数が合わなければ組み立て時に落ちる(fail-loud)。
* ★未実施・履歴なしの行は `0`★(指示書Z 決定13 / 3-2)。
*/
taskAreaBySerial: Record<string, number>;
}
/**
* ★★仕込んだケース(docs/fixtures.md §2.5 と同じ表)★★
*
* 完了率は `cleanLogs` 側が持っている値なので、ここでは面積だけを決める。
* ★`clean_area = task_area × completion_rate ÷ 100`★ になるように組み立てる(小数第6位で丸める)。
* ★行の完了率は面積から `runCompletionRate()` で決まる★(D-155)。★全体率は行の完了率の単純平均★
* (Σ面積 ÷ Σ面積 ではない。指示書AQ §5-5)。
* 施設7 の担当面積を違えてあるのは、★面積ベースに戻ったら判定が変わって気づける★ようにするため。
*/
const AREA_GROUPS: AreaGroupSpec[] = [
// --- 全体率の境界(1エリアだけの施設。行の完了率がそのまま全体率になる) ---
{
facility_id: 1,
daysBack: 0,
note: "1エリアのみ / 全体 90.1% = ◎(90%以上)",
taskAreaBySerial: { "8111R5628060011": 1000 },
},
{
facility_id: 2,
daysBack: 0,
note: "1エリアのみ / 全体 ちょうど90.0% = ◎(90%以上が◎。★D-139 で ○ から変わった★)",
taskAreaBySerial: { "8111R5628060022": 1000 },
},
{
facility_id: 5,
daysBack: 0,
note: "1エリアのみ / 全体 ちょうど70.0% = ○(70%以上が○。★D-139 で △ から変わった★)",
taskAreaBySerial: { "8111R5628060055": 1000 },
},
{
facility_id: 8,
daysBack: 0,
note: "1エリアのみ / 全体 ちょうど10.0% = △(10%未満が×)",
taskAreaBySerial: { "8111R5628060122": 1000 },
},
{
facility_id: 10,
daysBack: 0,
note: "1エリアのみ / 全体 0% = ×(★未実施ではなく「0%で実施した」★)",
taskAreaBySerial: { "8111R5628060144": 1000 },
},
{
facility_id: 12,
daysBack: 0,
note: "★全エリア未実施★ / completion_rate は null、平均する行が0件なので全体率も null(0% にしない)",
taskAreaBySerial: { "8111R5628060166": 0 },
},
// --- 複数エリアの施設 ---
{
facility_id: 7,
daysBack: 1,
note:
"★3エリア★ かつ ★担当面積が違う★: 全体率は行の完了率の単純平均 (62+94+88)/3 = 81.33% (○)。" +
"旧定義の面積ベース 802/1200 = 66.83…% (△) とは判定が割れる(面積ベースに戻っていないことの確認)",
taskAreaBySerial: {
"8111R5628060099": 1000, // 62.0%
"8111R5628060100": 100, // 94.0%
"8111R5628060177": 100, // 88.0%
},
},
{
facility_id: 5,
daysBack: 1,
note:
"★★履歴の行が無いエリア(出発失敗)と正常終了のエリアが同じ施設・同じ日に混在する★★: " +
"履歴なし側は completion_rate が null で単純平均の分母に入らないため、全体 97.0% = ◎",
taskAreaBySerial: {
"8111R5628060055": 0, // ★履歴の行が無い(出発失敗)→ 面積は 0★
"8111R5628060088": 1000, // 97.0%
},
},
// --- filter=problem で該当0件になる施設×日 ---
{
facility_id: 1,
daysBack: 1,
note: "★全エリアが◎○★ — filter=problem のとき items は空配列(404 にしない)",
taskAreaBySerial: { "8111R5628060011": 1000 },
},
];
/**
* 面積は小数第6位まで(`clean_task_reports.task_area` / `clean_area` は `double(12,6)`。D-094)。
* ★小数第1位(旧 `round1`)にしないこと★ — `task_area = 120` のような値で `runCompletionRate()` が
* 元の完了率から1段ずれる(2026-10-02 に node で確認。指示書AS 6-2)。
*/
function round6(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function logsOfGroup(group: AreaGroupSpec, date: string): CleanLogItem[] {
return cleanLogs
.filter(
(log) =>
log.facility_id === group.facility_id &&
log.scheduled_at.slice(0, 10) === date
)
.sort((a, b) => a.id - b.id);
}
/**
* ★組み立てに失敗したら落とす(fail-loud)★
*
* `lib/fixtures/clean-logs.ts` の行を書き換えると、ここの前提(施設×日のエリアの顔ぶれ)が
* 黙ってズレる。黙ってズレると ★「そのエリアが存在しない日」ができて画面が空になる★ ので、
* 読み込み時に気づけるようにしてある。
*/
function build(): AreaCompletionRow[] {
const out: AreaCompletionRow[] = [];
for (const group of AREA_GROUPS) {
const date = dateOnly(group.daysBack);
const logs = logsOfGroup(group, date);
const serials = Object.keys(group.taskAreaBySerial);
if (logs.length !== serials.length) {
throw new Error(
`[fixtures/areas] 施設${group.facility_id} の ${date} は clean-logs に ${logs.length}件ありますが、` +
`AREA_GROUPS は ${serials.length}件(${serials.join(" / ")})を想定しています`
);
}
for (const log of logs) {
const taskArea = group.taskAreaBySerial[log.serial_number];
if (taskArea === undefined) {
throw new Error(
`[fixtures/areas] 施設${group.facility_id} の ${date} に想定外のシリアル ${log.serial_number} があります`
);
}
const rate = log.completion_rate;
if (rate === null && taskArea !== 0) {
throw new Error(
`[fixtures/areas] 未実施(completion_rate=null)の行の task_area は 0 にしてください: ${log.serial_number}`
);
}
const sectionId = sectionIdOf(log.serial_number);
out.push({
clean_log_id: log.id,
facility_id: log.facility_id,
date,
section_id: sectionId,
section_name: sectionNameOf(sectionId),
completion_rate: rate,
// ★未実施は 0%(clean_area=0)ではなく「面積を持たない」★ — 分母に載せない
task_area: taskArea,
clean_area: rate === null ? 0 : round6((taskArea * rate) / 100),
});
}
}
return out;
}
export const areaCompletions: AreaCompletionRow[] = build();